@karmaniverous/jeeves-meta 0.16.3 → 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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { sleepAsync, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, fetchJson, postJson, getServiceUrl, jeevesComponentDescriptorSchema, createServiceCli } from '@karmaniverous/jeeves';
2
+ import { sleepAsync, fetchWithTimeout, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, fetchJson, postJson, getServiceUrl, jeevesComponentDescriptorSchema, createServiceCli } from '@karmaniverous/jeeves';
3
3
  import { normalizePath, metaConfigSchema, getEndpoint, META_COMPONENT } from '@karmaniverous/jeeves-meta-core';
4
4
  import fs, { unlinkSync, writeFileSync, existsSync, readFileSync, statSync, mkdirSync, readdirSync, watchFile } from 'node:fs';
5
5
  import path, { join, dirname, relative, posix, resolve } from 'node:path';
@@ -763,6 +763,12 @@ function applyHotReloadedConfig(newConfig) {
763
763
  *
764
764
  * @module schema/config
765
765
  */
766
+ /** Default Handlebars template strings for progress reporting. Single source of truth. */
767
+ const DEFAULT_TEMPLATE_STRINGS = {
768
+ phaseStart: ':gear: Started meta synthesis {{phase}} phase of <{{dirLink}}>',
769
+ phaseEnd: ':white_check_mark: Completed meta synthesis {{phase}} phase ({{tokens}} tokens / {{seconds}}s) at <{{metaLink}}>',
770
+ phaseError: ':x: Meta synthesis {{phase}} phase failed at <{{dirLink}}>\n Error: {{error}}',
771
+ };
766
772
  /** Zod schema for logging configuration. */
767
773
  const loggingSchema = z.object({
768
774
  /** Log level. */
@@ -791,14 +797,24 @@ const serviceConfigSchema = metaConfigSchema.extend({
791
797
  reportChannel: z.string().optional(),
792
798
  /** Channel/user ID to send progress messages to. */
793
799
  reportTarget: z.string().optional(),
794
- /** Optional base URL for the service, used to construct entity links in progress reports. */
795
- serverBaseUrl: z.string().optional(),
796
800
  /**
797
- * Optional mapping of server drive labels to absolute filesystem paths.
798
- * Used on Linux to resolve absolute paths to jeeves-server browse paths.
799
- * Example: `{ "content": "/opt/jeeves/content" }`
801
+ * URL of the local jeeves-server instance, used by the ProgressReporter
802
+ * to resolve filesystem paths to browse links via the resolve-path API.
803
+ * Default: http://127.0.0.1:1934
804
+ */
805
+ serverUrl: z.string().default('http://127.0.0.1:1934'),
806
+ /**
807
+ * Handlebars templates for progress reporting messages.
808
+ * Each template receives a standard set of data keys (dirLink, metaLink,
809
+ * phase, tokens, seconds, error).
800
810
  */
801
- serverDriveRoots: z.record(z.string(), z.string()).optional(),
811
+ templates: z
812
+ .object({
813
+ phaseStart: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseStart),
814
+ phaseEnd: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseEnd),
815
+ phaseError: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseError),
816
+ })
817
+ .default(() => ({ ...DEFAULT_TEMPLATE_STRINGS })),
802
818
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
803
819
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
804
820
  /** Logging configuration. */
@@ -3055,128 +3071,118 @@ async function executePhase(node, currentMeta, phaseState, phase, config, execut
3055
3071
  /**
3056
3072
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3057
3073
  *
3074
+ * Progress events are rendered using Handlebars templates. Owner-path links
3075
+ * are resolved via the jeeves-server resolve-path API, with graceful
3076
+ * fallback to raw filesystem paths when the server is unreachable.
3077
+ *
3058
3078
  * @module progress
3059
3079
  */
3080
+ /** Compile raw template strings into Handlebars delegates. */
3081
+ function compileTemplates(raw) {
3082
+ const merged = {
3083
+ phaseStart: raw?.phaseStart ?? DEFAULT_TEMPLATE_STRINGS.phaseStart,
3084
+ phaseEnd: raw?.phaseEnd ?? DEFAULT_TEMPLATE_STRINGS.phaseEnd,
3085
+ phaseError: raw?.phaseError ?? DEFAULT_TEMPLATE_STRINGS.phaseError,
3086
+ };
3087
+ return {
3088
+ phaseStart: Handlebars.compile(merged.phaseStart),
3089
+ phaseEnd: Handlebars.compile(merged.phaseEnd),
3090
+ phaseError: Handlebars.compile(merged.phaseError),
3091
+ };
3092
+ }
3060
3093
  function formatNumber(n) {
3061
3094
  return n.toLocaleString('en-US');
3062
3095
  }
3063
- function formatTokens(tokens) {
3064
- return tokens !== undefined
3065
- ? formatNumber(tokens) + ' tokens'
3066
- : 'unknown tokens';
3067
- }
3068
- function formatSeconds(durationMs) {
3069
- const seconds = durationMs / 1000;
3070
- return Math.round(seconds).toString() + 's';
3071
- }
3072
- function titleCasePhase(phase) {
3073
- return phase.charAt(0).toUpperCase() + phase.slice(1);
3074
- }
3075
- /**
3076
- * URL-encode each path segment individually so that spaces and special
3077
- * characters are safe while preserving the `/` separators.
3078
- */
3079
- function encodePathSegments(p) {
3080
- return p
3081
- .split('/')
3082
- .map((seg) => encodeURIComponent(seg))
3083
- .join('/');
3084
- }
3085
- /**
3086
- * Resolve a filesystem path to a jeeves-server browse path segment.
3087
- *
3088
- * - Windows: `j:/domains/foo` → `/j/domains/foo` (drive-letter transform).
3089
- * - Linux with serverDriveRoots: `/opt/jeeves/content/slack` + roots
3090
- * `{ content: "/opt/jeeves/content" }` → `/content/slack`.
3091
- * - Linux without serverDriveRoots: returns the normalized path unchanged
3092
- * (browse link will be invalid, but this is the pre-existing behavior).
3093
- */
3094
- function resolveServerPath(path, serverDriveRoots) {
3095
- const normalized = normalizePath(path);
3096
- // Windows: drive letter present — use existing transform
3097
- if (/^[A-Za-z]:/.test(normalized)) {
3098
- return normalized.replace(/^([A-Za-z]):/, '/$1');
3099
- }
3100
- // Linux: attempt serverDriveRoots resolution (longest/most-specific root wins)
3101
- if (serverDriveRoots) {
3102
- const sorted = Object.entries(serverDriveRoots)
3103
- .map(([label, root]) => [label, normalizePath(root).replace(/\/+$/, '')])
3104
- .sort((a, b) => b[1].length - a[1].length);
3105
- for (const [label, normalizedRoot] of sorted) {
3106
- if (normalized === normalizedRoot ||
3107
- normalized.startsWith(normalizedRoot + '/')) {
3108
- const relative = normalized
3109
- .slice(normalizedRoot.length)
3110
- .replace(/^\//, '');
3111
- return `/${label}${relative ? '/' + relative : ''}`;
3112
- }
3096
+ /**
3097
+ * Resolve a filesystem path to a browse URL via the jeeves-server
3098
+ * resolve-path API. Returns the raw path as fallback on any failure.
3099
+ *
3100
+ * On HTTP 200, uses publicUrl if present, otherwise prefixes serverUrl
3101
+ * to browseUrl. On any error or non-200 response, returns fsPath as-is.
3102
+ */
3103
+ /** Timeout for resolve-path API calls (ms). */
3104
+ const RESOLVE_PATH_TIMEOUT_MS = 3000;
3105
+ async function resolveLink(fsPath, serverUrl) {
3106
+ try {
3107
+ const url = new URL('/api/resolve-path', serverUrl);
3108
+ url.searchParams.set('fsPath', fsPath);
3109
+ const res = await fetchWithTimeout(url.toString(), RESOLVE_PATH_TIMEOUT_MS);
3110
+ if (!res.ok)
3111
+ return fsPath;
3112
+ const data = (await res.json());
3113
+ if (data.publicUrl)
3114
+ return data.publicUrl;
3115
+ if (data.browseUrl) {
3116
+ const base = serverUrl.replace(/\/+$/, '');
3117
+ return base + data.browseUrl;
3113
3118
  }
3119
+ return fsPath;
3114
3120
  }
3115
- // Fallback: no drive roots configured or no match — return as-is
3116
- return normalized;
3117
- }
3118
- /** Build a link (or plain path) to the owner directory. */
3119
- function buildDirectoryLink(path, serverBaseUrl, serverDriveRoots) {
3120
- const resolved = resolveServerPath(path, serverDriveRoots);
3121
- const encoded = encodePathSegments(resolved);
3122
- if (!serverBaseUrl)
3123
- return resolved;
3124
- const base = serverBaseUrl.replace(/\/+$/, '');
3125
- return `${base}/path${encoded}`;
3126
- }
3127
- /** Build a link (or plain path) to the entity's meta.json output file. */
3128
- function buildMetaJsonLink(path, serverBaseUrl, serverDriveRoots) {
3129
- const resolved = resolveServerPath(path, serverDriveRoots);
3130
- const metaJsonPath = `${resolved}/.meta/meta.json`;
3131
- const encoded = encodePathSegments(metaJsonPath);
3132
- if (!serverBaseUrl)
3133
- return metaJsonPath;
3134
- const base = serverBaseUrl.replace(/\/+$/, '');
3135
- return `${base}/path${encoded}`;
3136
- }
3137
- function formatProgressEvent(event, serverBaseUrl, serverDriveRoots) {
3121
+ catch {
3122
+ return fsPath;
3123
+ }
3124
+ }
3125
+ /** Resolve browse links for both the owner directory and its meta.json. */
3126
+ async function resolveLinks(ownerPath, serverUrl) {
3127
+ const dirLink = await resolveLink(ownerPath, serverUrl);
3128
+ // Derive metaLink by appending /.meta/meta.json to the resolved dir link
3129
+ const metaLink = dirLink.replace(/\/+$/, '') + '/.meta/meta.json';
3130
+ return { dirLink, metaLink };
3131
+ }
3132
+ /**
3133
+ * Render the appropriate template for a progress event.
3134
+ *
3135
+ * @param event - The progress event to render.
3136
+ * @param templates - Compiled Handlebars templates.
3137
+ * @param serverUrl - jeeves-server URL for resolve-path API.
3138
+ * @returns Rendered message string.
3139
+ */
3140
+ async function renderProgressEvent(event, templates, serverUrl) {
3141
+ const ownerPath = event.path;
3142
+ const metaPath = ownerPath.replace(/\/+$/, '') + '/.meta/meta.json';
3143
+ const phase = (event.phase ?? 'unknown').toUpperCase();
3144
+ const { dirLink, metaLink } = await resolveLinks(ownerPath, serverUrl);
3145
+ const base = {
3146
+ dirPath: ownerPath,
3147
+ metaPath,
3148
+ dirLink,
3149
+ metaLink,
3150
+ phase,
3151
+ };
3138
3152
  switch (event.type) {
3139
- case 'synthesis_start': {
3140
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3141
- return `🔬 Started meta synthesis: ${dirLink}`;
3142
- }
3143
- case 'phase_start': {
3144
- if (!event.phase) {
3145
- return ' ⚙️ Phase started';
3146
- }
3147
- return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
3148
- }
3153
+ case 'phase_start':
3154
+ return templates.phaseStart(base);
3149
3155
  case 'phase_complete': {
3150
- const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
3151
- const tokenStr = formatTokens(event.tokens);
3152
- const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
3153
- return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
3154
- }
3155
- case 'synthesis_complete': {
3156
- const metaLink = buildMetaJsonLink(event.path, serverBaseUrl, serverDriveRoots);
3157
- const tokenStr = formatTokens(event.tokens);
3158
- const duration = event.durationMs !== undefined
3159
- ? formatSeconds(event.durationMs)
3160
- : '0.0s';
3161
- return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
3156
+ const seconds = event.durationMs !== undefined
3157
+ ? String(Math.round(event.durationMs / 1000))
3158
+ : '0';
3159
+ const tokens = event.tokens !== undefined ? formatNumber(event.tokens) : 'unknown';
3160
+ return templates.phaseEnd({ ...base, seconds, tokens });
3162
3161
  }
3163
3162
  case 'error': {
3164
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3165
- const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
3166
- const error = event.error ?? 'Unknown error';
3167
- return `❌ Synthesis failed at ${phase}phase: ${dirLink}\n Error: ${error}`;
3163
+ const seconds = event.durationMs !== undefined
3164
+ ? String(Math.round(event.durationMs / 1000))
3165
+ : '0';
3166
+ const errorMsg = event.error ?? 'Unknown error';
3167
+ return templates.phaseError({ ...base, seconds, error: errorMsg });
3168
3168
  }
3169
- default: {
3169
+ default:
3170
3170
  return 'Unknown progress event';
3171
- }
3172
3171
  }
3173
3172
  }
3174
3173
  class ProgressReporter {
3175
3174
  config;
3176
3175
  logger;
3176
+ templates;
3177
+ /** Snapshot of template source strings used to compile `this.templates`. */
3178
+ lastTemplateSource;
3179
+ serverUrl;
3177
3180
  constructor(config, logger) {
3178
3181
  this.config = config;
3179
3182
  this.logger = logger;
3183
+ this.templates = compileTemplates(config.templates);
3184
+ this.lastTemplateSource = JSON.stringify(config.templates);
3185
+ this.serverUrl = config.serverUrl ?? 'http://127.0.0.1:1934';
3180
3186
  }
3181
3187
  async report(event) {
3182
3188
  // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
@@ -3184,7 +3190,27 @@ class ProgressReporter {
3184
3190
  const target = this.config.reportTarget ?? this.config.reportChannel;
3185
3191
  if (!target)
3186
3192
  return;
3187
- const message = formatProgressEvent(event, this.config.serverBaseUrl, this.config.serverDriveRoots);
3193
+ // Detect template hot-reload: config.templates may be mutated by
3194
+ // applyHotReloadedConfig; recompile when the source strings change.
3195
+ const currentSource = JSON.stringify(this.config.templates);
3196
+ if (currentSource !== this.lastTemplateSource) {
3197
+ try {
3198
+ this.templates = compileTemplates(this.config.templates);
3199
+ this.lastTemplateSource = currentSource;
3200
+ this.logger.info('Progress templates recompiled (hot-reload)');
3201
+ }
3202
+ catch (err) {
3203
+ this.logger.warn({ err }, 'Failed to compile hot-reloaded templates; keeping previous templates');
3204
+ }
3205
+ }
3206
+ let message;
3207
+ try {
3208
+ message = await renderProgressEvent(event, this.templates, this.serverUrl);
3209
+ }
3210
+ catch (err) {
3211
+ this.logger.warn({ err }, 'Progress event rendering failed');
3212
+ return;
3213
+ }
3188
3214
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
3189
3215
  const args = {
3190
3216
  action: 'send',
@@ -4762,6 +4788,7 @@ function registerStatusRoute(app, deps) {
4762
4788
  critic: emptyPhaseCounts(),
4763
4789
  };
4764
4790
  let nextPhase = null;
4791
+ let metaCounts = null;
4765
4792
  try {
4766
4793
  const metaResult = await cache.get(config, watcher);
4767
4794
  // Count raw phase states (before retry) for display
@@ -4783,6 +4810,17 @@ function registerStatusRoute(app, deps) {
4783
4810
  staleness: winner.effectiveStaleness,
4784
4811
  };
4785
4812
  }
4813
+ // Meta counts summary
4814
+ const summary = computeSummary(metaResult.entries, config.depthWeight);
4815
+ metaCounts = {
4816
+ total: summary.total,
4817
+ enabled: summary.total - summary.disabled,
4818
+ disabled: summary.disabled,
4819
+ neverSynthesized: summary.neverSynthesized,
4820
+ stale: summary.stale,
4821
+ errors: summary.errors,
4822
+ locked: summary.locked,
4823
+ };
4786
4824
  }
4787
4825
  catch {
4788
4826
  // Watcher unreachable — phase summary unavailable
@@ -4811,6 +4849,7 @@ function registerStatusRoute(app, deps) {
4811
4849
  },
4812
4850
  phaseStateSummary,
4813
4851
  nextPhase,
4852
+ metaCounts,
4814
4853
  };
4815
4854
  },
4816
4855
  });
@@ -5120,11 +5159,10 @@ class HttpWatcherClient {
5120
5159
  async post(endpoint, body) {
5121
5160
  const url = this.baseUrl + endpoint;
5122
5161
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
5123
- const res = await fetch(url, {
5162
+ const res = await fetchWithTimeout(url, this.timeoutMs, {
5124
5163
  method: 'POST',
5125
5164
  headers: { 'Content-Type': 'application/json' },
5126
5165
  body: JSON.stringify(body),
5127
- signal: AbortSignal.timeout(this.timeoutMs),
5128
5166
  });
5129
5167
  if (res.ok) {
5130
5168
  return res.json();
@@ -5166,25 +5204,6 @@ class HttpWatcherClient {
5166
5204
  *
5167
5205
  * @module bootstrap
5168
5206
  */
5169
- /**
5170
- * Compute per-cycle token total from a completed meta.
5171
- *
5172
- * Exported for testing.
5173
- *
5174
- * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5175
- * a value of 1 means architect ran this cycle (was 0 pre-increment),
5176
- * so all three phase token fields are summed. A value \> 1 means architect
5177
- * was skipped (cached brief reused), so only builder + critic are summed.
5178
- */
5179
- function computeCycleTokens(meta) {
5180
- const builderTokens = meta._builderTokens ?? 0;
5181
- const criticTokens = meta._criticTokens ?? 0;
5182
- const architectRan = (meta._synthesisCount ?? 1) === 1;
5183
- const architectTokens = architectRan
5184
- ? (meta._architectTokens ?? 0)
5185
- : 0;
5186
- return architectTokens + builderTokens + criticTokens;
5187
- }
5188
5207
  /**
5189
5208
  * Bootstrap the service: create logger, build server, start listening,
5190
5209
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5261,16 +5280,7 @@ async function startService(config, configPath) {
5261
5280
  // Track whether synthesis_start has been emitted (deferred until
5262
5281
  // a phase actually begins, avoiding orphan "Started" messages
5263
5282
  // when the meta is skipped/locked/fresh). See #165.
5264
- let synthesisStartEmitted = false;
5265
5283
  const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
5266
- // Emit synthesis_start on first phase_start (deferred guard)
5267
- if (evt.type === 'phase_start' && !synthesisStartEmitted) {
5268
- synthesisStartEmitted = true;
5269
- await progress.report({
5270
- type: 'synthesis_start',
5271
- path: ownerPath,
5272
- });
5273
- }
5274
5284
  // Wire current-phase tracking for GET /queue and POST /synthesize/abort
5275
5285
  if (evt.type === 'phase_start' && evt.phase) {
5276
5286
  queue.setCurrentPhase(ownerPath, evt.phase);
@@ -5312,19 +5322,8 @@ async function startService(config, configPath) {
5312
5322
  // Task #9: Reset backoff on ANY successful phase execution
5313
5323
  scheduler.resetBackoff();
5314
5324
  }
5315
- // Emit synthesis_complete only on full-cycle completion
5316
- if (result.cycleComplete) {
5317
- const updatedMeta = result.phaseResult?.updatedMeta;
5318
- const tokens = updatedMeta
5319
- ? computeCycleTokens(updatedMeta)
5320
- : undefined;
5321
- await progress.report({
5322
- type: 'synthesis_complete',
5323
- path: ownerPath,
5324
- durationMs,
5325
- tokens,
5326
- });
5327
- }
5325
+ // Note: synthesis_complete is no longer emitted; the phaseEnd template
5326
+ // for the final critic phase serves as the completion signal (#129).
5328
5327
  }
5329
5328
  catch (err) {
5330
5329
  stats.totalErrors++;
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export type { InferenceRuleSpec, MetaContext, MetaExecutor, MetaSpawnOptions, Me
19
19
  export type { LoggerConfig } from './logger/index.js';
20
20
  export { createLogger, type MinimalLogger } from './logger/index.js';
21
21
  export { buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, type BuilderOutput, orchestratePhase, type OrchestratePhaseResult, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, type PhaseProgressCallback, type PhaseResult, runArchitect, runBuilder, runCritic, } from './orchestrator/index.js';
22
- export { formatProgressEvent, type ProgressEvent, type ProgressPhase, ProgressReporter, type ProgressReporterConfig, } from './progress/index.js';
22
+ export { type ProgressEvent, type ProgressPhase, ProgressReporter, type ProgressReporterConfig, renderProgressEvent, type TemplateStrings, } from './progress/index.js';
23
23
  export { actualStaleness, computeEffectiveStaleness, hasSteerChanged, isArchitectTriggered, isStale, MAX_STALENESS_SECONDS, type StalenessCandidate, } from './scheduling/index.js';
24
24
  export { type MetaConfig, metaConfigSchema, type MetaError, metaErrorSchema, type MetaJson, metaJsonSchema, type ServiceConfig, serviceConfigSchema, } from './schema/index.js';
25
25
  export { Scheduler } from './scheduler/index.js';
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';
@@ -1252,6 +1252,12 @@ function applyHotReloadedConfig(newConfig) {
1252
1252
  *
1253
1253
  * @module schema/config
1254
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
+ };
1255
1261
  /** Zod schema for logging configuration. */
1256
1262
  const loggingSchema = z.object({
1257
1263
  /** Log level. */
@@ -1280,14 +1286,24 @@ const serviceConfigSchema = metaConfigSchema.extend({
1280
1286
  reportChannel: z.string().optional(),
1281
1287
  /** Channel/user ID to send progress messages to. */
1282
1288
  reportTarget: z.string().optional(),
1283
- /** Optional base URL for the service, used to construct entity links in progress reports. */
1284
- serverBaseUrl: z.string().optional(),
1285
1289
  /**
1286
- * Optional mapping of server drive labels to absolute filesystem paths.
1287
- * Used on Linux to resolve absolute paths to jeeves-server browse paths.
1288
- * Example: `{ "content": "/opt/jeeves/content" }`
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).
1289
1299
  */
1290
- serverDriveRoots: z.record(z.string(), z.string()).optional(),
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 })),
1291
1307
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
1292
1308
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
1293
1309
  /** Logging configuration. */
@@ -3313,128 +3329,118 @@ async function executePhase(node, currentMeta, phaseState, phase, config, execut
3313
3329
  /**
3314
3330
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3315
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
+ *
3316
3336
  * @module progress
3317
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
+ }
3318
3351
  function formatNumber(n) {
3319
3352
  return n.toLocaleString('en-US');
3320
3353
  }
3321
- function formatTokens(tokens) {
3322
- return tokens !== undefined
3323
- ? formatNumber(tokens) + ' tokens'
3324
- : 'unknown tokens';
3325
- }
3326
- function formatSeconds(durationMs) {
3327
- const seconds = durationMs / 1000;
3328
- return Math.round(seconds).toString() + 's';
3329
- }
3330
- function titleCasePhase(phase) {
3331
- return phase.charAt(0).toUpperCase() + phase.slice(1);
3332
- }
3333
- /**
3334
- * URL-encode each path segment individually so that spaces and special
3335
- * characters are safe while preserving the `/` separators.
3336
- */
3337
- function encodePathSegments(p) {
3338
- return p
3339
- .split('/')
3340
- .map((seg) => encodeURIComponent(seg))
3341
- .join('/');
3342
- }
3343
- /**
3344
- * Resolve a filesystem path to a jeeves-server browse path segment.
3345
- *
3346
- * - Windows: `j:/domains/foo` → `/j/domains/foo` (drive-letter transform).
3347
- * - Linux with serverDriveRoots: `/opt/jeeves/content/slack` + roots
3348
- * `{ content: "/opt/jeeves/content" }` → `/content/slack`.
3349
- * - Linux without serverDriveRoots: returns the normalized path unchanged
3350
- * (browse link will be invalid, but this is the pre-existing behavior).
3351
- */
3352
- function resolveServerPath(path, serverDriveRoots) {
3353
- const normalized = normalizePath(path);
3354
- // Windows: drive letter present — use existing transform
3355
- if (/^[A-Za-z]:/.test(normalized)) {
3356
- return normalized.replace(/^([A-Za-z]):/, '/$1');
3357
- }
3358
- // Linux: attempt serverDriveRoots resolution (longest/most-specific root wins)
3359
- if (serverDriveRoots) {
3360
- const sorted = Object.entries(serverDriveRoots)
3361
- .map(([label, root]) => [label, normalizePath(root).replace(/\/+$/, '')])
3362
- .sort((a, b) => b[1].length - a[1].length);
3363
- for (const [label, normalizedRoot] of sorted) {
3364
- if (normalized === normalizedRoot ||
3365
- normalized.startsWith(normalizedRoot + '/')) {
3366
- const relative = normalized
3367
- .slice(normalizedRoot.length)
3368
- .replace(/^\//, '');
3369
- return `/${label}${relative ? '/' + relative : ''}`;
3370
- }
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;
3371
3376
  }
3377
+ return fsPath;
3372
3378
  }
3373
- // Fallback: no drive roots configured or no match — return as-is
3374
- return normalized;
3375
- }
3376
- /** Build a link (or plain path) to the owner directory. */
3377
- function buildDirectoryLink(path, serverBaseUrl, serverDriveRoots) {
3378
- const resolved = resolveServerPath(path, serverDriveRoots);
3379
- const encoded = encodePathSegments(resolved);
3380
- if (!serverBaseUrl)
3381
- return resolved;
3382
- const base = serverBaseUrl.replace(/\/+$/, '');
3383
- return `${base}/path${encoded}`;
3384
- }
3385
- /** Build a link (or plain path) to the entity's meta.json output file. */
3386
- function buildMetaJsonLink(path, serverBaseUrl, serverDriveRoots) {
3387
- const resolved = resolveServerPath(path, serverDriveRoots);
3388
- const metaJsonPath = `${resolved}/.meta/meta.json`;
3389
- const encoded = encodePathSegments(metaJsonPath);
3390
- if (!serverBaseUrl)
3391
- return metaJsonPath;
3392
- const base = serverBaseUrl.replace(/\/+$/, '');
3393
- return `${base}/path${encoded}`;
3394
- }
3395
- function formatProgressEvent(event, serverBaseUrl, serverDriveRoots) {
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
+ };
3396
3410
  switch (event.type) {
3397
- case 'synthesis_start': {
3398
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3399
- return `🔬 Started meta synthesis: ${dirLink}`;
3400
- }
3401
- case 'phase_start': {
3402
- if (!event.phase) {
3403
- return ' ⚙️ Phase started';
3404
- }
3405
- return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
3406
- }
3411
+ case 'phase_start':
3412
+ return templates.phaseStart(base);
3407
3413
  case 'phase_complete': {
3408
- const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
3409
- const tokenStr = formatTokens(event.tokens);
3410
- const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
3411
- return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
3412
- }
3413
- case 'synthesis_complete': {
3414
- const metaLink = buildMetaJsonLink(event.path, serverBaseUrl, serverDriveRoots);
3415
- const tokenStr = formatTokens(event.tokens);
3416
- const duration = event.durationMs !== undefined
3417
- ? formatSeconds(event.durationMs)
3418
- : '0.0s';
3419
- 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 });
3420
3419
  }
3421
3420
  case 'error': {
3422
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3423
- const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
3424
- const error = event.error ?? 'Unknown error';
3425
- 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 });
3426
3426
  }
3427
- default: {
3427
+ default:
3428
3428
  return 'Unknown progress event';
3429
- }
3430
3429
  }
3431
3430
  }
3432
3431
  class ProgressReporter {
3433
3432
  config;
3434
3433
  logger;
3434
+ templates;
3435
+ /** Snapshot of template source strings used to compile `this.templates`. */
3436
+ lastTemplateSource;
3437
+ serverUrl;
3435
3438
  constructor(config, logger) {
3436
3439
  this.config = config;
3437
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';
3438
3444
  }
3439
3445
  async report(event) {
3440
3446
  // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
@@ -3442,7 +3448,27 @@ class ProgressReporter {
3442
3448
  const target = this.config.reportTarget ?? this.config.reportChannel;
3443
3449
  if (!target)
3444
3450
  return;
3445
- const message = formatProgressEvent(event, this.config.serverBaseUrl, this.config.serverDriveRoots);
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
+ }
3446
3472
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
3447
3473
  const args = {
3448
3474
  action: 'send',
@@ -4992,6 +5018,7 @@ function registerStatusRoute(app, deps) {
4992
5018
  critic: emptyPhaseCounts(),
4993
5019
  };
4994
5020
  let nextPhase = null;
5021
+ let metaCounts = null;
4995
5022
  try {
4996
5023
  const metaResult = await cache.get(config, watcher);
4997
5024
  // Count raw phase states (before retry) for display
@@ -5013,6 +5040,17 @@ function registerStatusRoute(app, deps) {
5013
5040
  staleness: winner.effectiveStaleness,
5014
5041
  };
5015
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
+ };
5016
5054
  }
5017
5055
  catch {
5018
5056
  // Watcher unreachable — phase summary unavailable
@@ -5041,6 +5079,7 @@ function registerStatusRoute(app, deps) {
5041
5079
  },
5042
5080
  phaseStateSummary,
5043
5081
  nextPhase,
5082
+ metaCounts,
5044
5083
  };
5045
5084
  },
5046
5085
  });
@@ -5350,11 +5389,10 @@ class HttpWatcherClient {
5350
5389
  async post(endpoint, body) {
5351
5390
  const url = this.baseUrl + endpoint;
5352
5391
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
5353
- const res = await fetch(url, {
5392
+ const res = await fetchWithTimeout(url, this.timeoutMs, {
5354
5393
  method: 'POST',
5355
5394
  headers: { 'Content-Type': 'application/json' },
5356
5395
  body: JSON.stringify(body),
5357
- signal: AbortSignal.timeout(this.timeoutMs),
5358
5396
  });
5359
5397
  if (res.ok) {
5360
5398
  return res.json();
@@ -5396,25 +5434,6 @@ class HttpWatcherClient {
5396
5434
  *
5397
5435
  * @module bootstrap
5398
5436
  */
5399
- /**
5400
- * Compute per-cycle token total from a completed meta.
5401
- *
5402
- * Exported for testing.
5403
- *
5404
- * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5405
- * a value of 1 means architect ran this cycle (was 0 pre-increment),
5406
- * so all three phase token fields are summed. A value \> 1 means architect
5407
- * was skipped (cached brief reused), so only builder + critic are summed.
5408
- */
5409
- function computeCycleTokens(meta) {
5410
- const builderTokens = meta._builderTokens ?? 0;
5411
- const criticTokens = meta._criticTokens ?? 0;
5412
- const architectRan = (meta._synthesisCount ?? 1) === 1;
5413
- const architectTokens = architectRan
5414
- ? (meta._architectTokens ?? 0)
5415
- : 0;
5416
- return architectTokens + builderTokens + criticTokens;
5417
- }
5418
5437
  /**
5419
5438
  * Bootstrap the service: create logger, build server, start listening,
5420
5439
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5491,16 +5510,7 @@ async function startService(config, configPath) {
5491
5510
  // Track whether synthesis_start has been emitted (deferred until
5492
5511
  // a phase actually begins, avoiding orphan "Started" messages
5493
5512
  // when the meta is skipped/locked/fresh). See #165.
5494
- let synthesisStartEmitted = false;
5495
5513
  const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
5496
- // Emit synthesis_start on first phase_start (deferred guard)
5497
- if (evt.type === 'phase_start' && !synthesisStartEmitted) {
5498
- synthesisStartEmitted = true;
5499
- await progress.report({
5500
- type: 'synthesis_start',
5501
- path: ownerPath,
5502
- });
5503
- }
5504
5514
  // Wire current-phase tracking for GET /queue and POST /synthesize/abort
5505
5515
  if (evt.type === 'phase_start' && evt.phase) {
5506
5516
  queue.setCurrentPhase(ownerPath, evt.phase);
@@ -5542,19 +5552,8 @@ async function startService(config, configPath) {
5542
5552
  // Task #9: Reset backoff on ANY successful phase execution
5543
5553
  scheduler.resetBackoff();
5544
5554
  }
5545
- // Emit synthesis_complete only on full-cycle completion
5546
- if (result.cycleComplete) {
5547
- const updatedMeta = result.phaseResult?.updatedMeta;
5548
- const tokens = updatedMeta
5549
- ? computeCycleTokens(updatedMeta)
5550
- : undefined;
5551
- await progress.report({
5552
- type: 'synthesis_complete',
5553
- path: ownerPath,
5554
- durationMs,
5555
- tokens,
5556
- });
5557
- }
5555
+ // Note: synthesis_complete is no longer emitted; the phaseEnd template
5556
+ // for the final critic phase serves as the completion signal (#129).
5558
5557
  }
5559
5558
  catch (err) {
5560
5559
  stats.totalErrors++;
@@ -5795,4 +5794,4 @@ const metaJsonSchema = z
5795
5794
  })
5796
5795
  .loose();
5797
5796
 
5798
- 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 };
@@ -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,19 +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;
31
45
  /**
32
- * Optional mapping of server drive labels to absolute filesystem paths.
33
- * Used on Linux to resolve absolute paths to jeeves-server browse paths.
34
- * Example: `{ "content": "/opt/jeeves/content" }`
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
35
49
  */
36
- serverDriveRoots?: Record<string, string>;
50
+ serverUrl?: string;
51
+ /** Handlebars template strings for each progress event type. */
52
+ templates?: Partial<TemplateStrings>;
37
53
  };
38
- export declare function formatProgressEvent(event: ProgressEvent, serverBaseUrl?: string, serverDriveRoots?: Record<string, 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>;
39
63
  export declare class ProgressReporter {
40
64
  private readonly config;
41
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;
42
70
  constructor(config: ProgressReporterConfig, logger: Logger);
43
71
  report(event: ProgressEvent): Promise<void>;
44
72
  }
73
+ export {};
@@ -8,6 +8,12 @@
8
8
  import { type MetaConfig, metaConfigSchema } from '@karmaniverous/jeeves-meta-core';
9
9
  import { z } from 'zod';
10
10
  export { type MetaConfig, metaConfigSchema };
11
+ /** Default Handlebars template strings for progress reporting. Single source of truth. */
12
+ export declare const DEFAULT_TEMPLATE_STRINGS: {
13
+ readonly phaseStart: ":gear: Started meta synthesis {{phase}} phase of <{{dirLink}}>";
14
+ readonly phaseEnd: ":white_check_mark: Completed meta synthesis {{phase}} phase ({{tokens}} tokens / {{seconds}}s) at <{{metaLink}}>";
15
+ readonly phaseError: ":x: Meta synthesis {{phase}} phase failed at <{{dirLink}}>\n Error: {{error}}";
16
+ };
11
17
  /** Zod schema for a single auto-seed policy rule. */
12
18
  declare const autoSeedRuleSchema: z.ZodObject<{
13
19
  match: z.ZodString;
@@ -34,8 +40,12 @@ export declare const serviceConfigSchema: z.ZodObject<{
34
40
  schedule: z.ZodDefault<z.ZodString>;
35
41
  reportChannel: z.ZodOptional<z.ZodString>;
36
42
  reportTarget: z.ZodOptional<z.ZodString>;
37
- serverBaseUrl: z.ZodOptional<z.ZodString>;
38
- serverDriveRoots: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
43
+ serverUrl: z.ZodDefault<z.ZodString>;
44
+ templates: z.ZodDefault<z.ZodObject<{
45
+ phaseStart: z.ZodDefault<z.ZodString>;
46
+ phaseEnd: z.ZodDefault<z.ZodString>;
47
+ phaseError: z.ZodDefault<z.ZodString>;
48
+ }, z.core.$strip>>;
39
49
  watcherHealthIntervalMs: z.ZodDefault<z.ZodNumber>;
40
50
  logging: z.ZodDefault<z.ZodObject<{
41
51
  level: z.ZodDefault<z.ZodString>;
@@ -3,6 +3,6 @@
3
3
  *
4
4
  * @module schema
5
5
  */
6
- export { type AutoSeedRule, type MetaConfig, metaConfigSchema, type ServiceConfig, serviceConfigSchema, } from './config.js';
6
+ export { type AutoSeedRule, DEFAULT_TEMPLATE_STRINGS, type MetaConfig, metaConfigSchema, type ServiceConfig, serviceConfigSchema, } from './config.js';
7
7
  export { type MetaError, metaErrorSchema } from './error.js';
8
8
  export { type MetaJson, metaJsonSchema, type PhaseName, phaseNames, type PhaseState, phaseStateSchema, type PhaseStatus, phaseStatuses, } from './meta.js';
package/package.json CHANGED
@@ -110,5 +110,5 @@
110
110
  },
111
111
  "type": "module",
112
112
  "types": "dist/index.d.ts",
113
- "version": "0.16.3"
113
+ "version": "0.17.0"
114
114
  }