@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/README.md CHANGED
@@ -19,7 +19,7 @@ HTTP service for the Jeeves knowledge synthesis engine. Provides a Fastify API,
19
19
  - **Virtual rule registration** — registers 3 watcher inference rules at startup with retry
20
20
  - **Progress reporting** — real-time synthesis events via gateway channel messages
21
21
  - **Graceful shutdown** — stop scheduler, release locks, close server
22
- - **Built-in prompts** — default architect and critic prompts ship with the package; optional config overrides via `@file:` or inline strings
22
+ - **Built-in prompts** — default architect and critic prompts ship with the package
23
23
  - **Handlebars templates** — prompts compiled with `{ config, meta, scope }` context; architect can write template expressions into builder briefs
24
24
  - **Config hot-reload** — all synthesis parameters reload without restart; restart-required fields (port, URLs) warn on change
25
25
  - **Auto-seed policy** — config-driven declarative `.meta/` creation via `autoSeed` rules
@@ -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';
@@ -714,8 +714,6 @@ const RESTART_REQUIRED_FIELDS = [
714
714
  'watcherUrl',
715
715
  'gatewayUrl',
716
716
  'gatewayApiKey',
717
- 'defaultArchitect',
718
- 'defaultCritic',
719
717
  ];
720
718
  let runtime = null;
721
719
  /** Register the active service runtime for config-apply hot reload. */
@@ -765,6 +763,12 @@ function applyHotReloadedConfig(newConfig) {
765
763
  *
766
764
  * @module schema/config
767
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
+ };
768
772
  /** Zod schema for logging configuration. */
769
773
  const loggingSchema = z.object({
770
774
  /** Log level. */
@@ -793,8 +797,24 @@ const serviceConfigSchema = metaConfigSchema.extend({
793
797
  reportChannel: z.string().optional(),
794
798
  /** Channel/user ID to send progress messages to. */
795
799
  reportTarget: z.string().optional(),
796
- /** Optional base URL for the service, used to construct entity links in progress reports. */
797
- serverBaseUrl: z.string().optional(),
800
+ /**
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).
810
+ */
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 })),
798
818
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
799
819
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
800
820
  /** Logging configuration. */
@@ -819,7 +839,7 @@ const serviceConfigSchema = metaConfigSchema.extend({
819
839
  /**
820
840
  * Load and resolve jeeves-meta service config.
821
841
  *
822
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
842
+ * Supports environment-variable substitution (dollar-brace pattern).
823
843
  *
824
844
  * @module configLoader
825
845
  */
@@ -851,24 +871,10 @@ function substituteEnvVars(value) {
851
871
  }
852
872
  return value;
853
873
  }
854
- /**
855
- * Resolve \@file: references in a config value.
856
- *
857
- * @param value - String value that may start with "\@file:".
858
- * @param baseDir - Base directory for resolving relative paths.
859
- * @returns The resolved string (file contents or original value).
860
- */
861
- function resolveFileRef(value, baseDir) {
862
- if (!value.startsWith('@file:'))
863
- return value;
864
- const filePath = join(baseDir, value.slice(6));
865
- return readFileSync(filePath, 'utf8');
866
- }
867
874
  /**
868
875
  * Load service config from a JSON file.
869
876
  *
870
- * Resolves \@file: references for defaultArchitect and defaultCritic,
871
- * and substitutes environment-variable placeholders throughout.
877
+ * Substitutes environment-variable placeholders throughout.
872
878
  *
873
879
  * @param configPath - Path to config JSON file.
874
880
  * @returns Validated ServiceConfig.
@@ -876,13 +882,6 @@ function resolveFileRef(value, baseDir) {
876
882
  function loadServiceConfig(configPath) {
877
883
  const rawText = readFileSync(configPath, 'utf8');
878
884
  const raw = substituteEnvVars(JSON.parse(rawText));
879
- const baseDir = dirname(configPath);
880
- if (typeof raw['defaultArchitect'] === 'string') {
881
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
882
- }
883
- if (typeof raw['defaultCritic'] === 'string') {
884
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
885
- }
886
885
  return serviceConfigSchema.parse(raw);
887
886
  }
888
887
 
@@ -1346,9 +1345,6 @@ function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
1346
1345
  * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
1347
1346
  * Loaded at runtime relative to the compiled module location.
1348
1347
  *
1349
- * Users can override via `defaultArchitect` / `defaultCritic` in the service
1350
- * config. Most installations should use the built-in defaults.
1351
- *
1352
1348
  * @module prompts
1353
1349
  */
1354
1350
  const packageRoot = packageDirectorySync({
@@ -1836,7 +1832,7 @@ function buildArchitectTask(ctx, meta, config) {
1836
1832
  const sections = [
1837
1833
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
1838
1834
  '',
1839
- config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
1835
+ DEFAULT_ARCHITECT_PROMPT,
1840
1836
  '',
1841
1837
  '## SCOPE',
1842
1838
  `Path: ${ctx.path}`,
@@ -1885,7 +1881,7 @@ function buildBuilderTask(ctx, meta, config) {
1885
1881
  includeSteer: false,
1886
1882
  feedbackHeading: '## FEEDBACK FROM CRITIC',
1887
1883
  });
1888
- 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.');
1884
+ 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.');
1889
1885
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
1890
1886
  }
1891
1887
  /**
@@ -1900,7 +1896,7 @@ function buildCriticTask(ctx, meta, config) {
1900
1896
  const sections = [
1901
1897
  `# jeeves-meta · CRITIC · ${ctx.path}`,
1902
1898
  '',
1903
- config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
1899
+ DEFAULT_CRITIC_PROMPT,
1904
1900
  '',
1905
1901
  '## SYNTHESIS TO EVALUATE',
1906
1902
  meta._content ?? '(No content produced)',
@@ -2317,8 +2313,8 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
2317
2313
  // through real builder cycles, architect runs with the current prompt and
2318
2314
  // the snapshot updates. Coupling prompt changes to invalidation causes a
2319
2315
  // corpus-wide synthesis storm (see #163).
2320
- const architectChanged = isPromptStale(meta._architect, config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT);
2321
- const criticChanged = isPromptStale(meta._critic, config.defaultCritic ?? DEFAULT_CRITIC_PROMPT);
2316
+ const architectChanged = isPromptStale(meta._architect, DEFAULT_ARCHITECT_PROMPT);
2317
+ const criticChanged = isPromptStale(meta._critic, DEFAULT_CRITIC_PROMPT);
2322
2318
  const effectiveSynthesisCount = meta._synthesisCount ?? 0;
2323
2319
  // _crossRefs declaration change
2324
2320
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
@@ -2565,10 +2561,11 @@ function parseArchitectOutput(output) {
2565
2561
  /**
2566
2562
  * Parse builder output. The builder returns JSON with _content and optional fields.
2567
2563
  *
2568
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
2564
+ * Attempts JSON extraction via multiple strategies. Returns null if all strategies fail
2565
+ * (e.g. the output is plain text, self-talk, or a sub-agent delegation response).
2569
2566
  *
2570
2567
  * @param output - Raw subprocess output.
2571
- * @returns Parsed builder output with content and structured fields.
2568
+ * @returns Parsed builder output, or null if output is not valid builder JSON.
2572
2569
  */
2573
2570
  function parseBuilderOutput(output) {
2574
2571
  const trimmed = stripSentinel(output);
@@ -2597,8 +2594,8 @@ function parseBuilderOutput(output) {
2597
2594
  if (result)
2598
2595
  return result;
2599
2596
  }
2600
- // Fallback: treat entire output as content
2601
- return { content: trimmed, fields: {} };
2597
+ // All JSON strategies failed not valid builder output
2598
+ return null;
2602
2599
  }
2603
2600
  /** Try to parse a string as JSON and extract builder output fields. */
2604
2601
  function tryParseJson(str) {
@@ -2726,7 +2723,7 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2726
2723
  ps = architectSuccess(ps);
2727
2724
  const architectUpdates = {
2728
2725
  _builder: builderBrief,
2729
- _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
2726
+ _architect: DEFAULT_ARCHITECT_PROMPT,
2730
2727
  _synthesisCount: 0,
2731
2728
  _architectTokens: architectTokens,
2732
2729
  _generatedAt: new Date().toISOString(),
@@ -2798,6 +2795,12 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2798
2795
  return { executed: true, phaseState: ps, updatedMeta };
2799
2796
  }
2800
2797
  const builderOutput = parseBuilderOutput(rawOutput);
2798
+ if (!builderOutput) {
2799
+ throw new Error('Builder output is not valid JSON — expected { _content|content: string, ... }. ' +
2800
+ 'Raw output (' +
2801
+ rawOutput.length.toString() +
2802
+ ' chars) did not match any JSON extraction strategy.');
2803
+ }
2801
2804
  const builderTokens = result.tokens;
2802
2805
  // Builder success: builder → fresh, critic → pending
2803
2806
  ps = builderSuccess(ps);
@@ -2829,7 +2832,8 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
2829
2832
  try {
2830
2833
  const raw = await readFile(err.outputPath, 'utf8');
2831
2834
  const partial = parseBuilderOutput(raw);
2832
- if (partial.state !== undefined &&
2835
+ if (partial !== null &&
2836
+ partial.state !== undefined &&
2833
2837
  JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
2834
2838
  partialState = { _state: partial.state };
2835
2839
  }
@@ -2872,7 +2876,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2872
2876
  const cycleComplete = isFullyFresh(ps);
2873
2877
  const updates = {
2874
2878
  _feedback: feedback,
2875
- _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
2879
+ _critic: DEFAULT_CRITIC_PROMPT,
2876
2880
  _criticTokens: criticTokens,
2877
2881
  _error: undefined,
2878
2882
  };
@@ -3067,95 +3071,118 @@ async function executePhase(node, currentMeta, phaseState, phase, config, execut
3067
3071
  /**
3068
3072
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3069
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
+ *
3070
3078
  * @module progress
3071
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
+ }
3072
3093
  function formatNumber(n) {
3073
3094
  return n.toLocaleString('en-US');
3074
3095
  }
3075
- function formatTokens(tokens) {
3076
- return tokens !== undefined
3077
- ? formatNumber(tokens) + ' tokens'
3078
- : 'unknown tokens';
3079
- }
3080
- function formatSeconds(durationMs) {
3081
- const seconds = durationMs / 1000;
3082
- return Math.round(seconds).toString() + 's';
3083
- }
3084
- function titleCasePhase(phase) {
3085
- return phase.charAt(0).toUpperCase() + phase.slice(1);
3086
- }
3087
- /**
3088
- * URL-encode each path segment individually so that spaces and special
3089
- * characters are safe while preserving the `/` separators.
3090
- */
3091
- function encodePathSegments(p) {
3092
- return p
3093
- .split('/')
3094
- .map((seg) => encodeURIComponent(seg))
3095
- .join('/');
3096
- }
3097
- /** Build a link (or plain path) to the owner directory. */
3098
- function buildDirectoryLink(path, serverBaseUrl) {
3099
- const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
3100
- const encoded = encodePathSegments(normalized);
3101
- if (!serverBaseUrl)
3102
- return normalized;
3103
- const base = serverBaseUrl.replace(/\/+$/, '');
3104
- return `${base}/path${encoded}`;
3105
- }
3106
- /** Build a link (or plain path) to the entity's meta.json output file. */
3107
- function buildMetaJsonLink(path, serverBaseUrl) {
3108
- const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
3109
- const metaJsonPath = `${normalized}/.meta/meta.json`;
3110
- const encoded = encodePathSegments(metaJsonPath);
3111
- if (!serverBaseUrl)
3112
- return metaJsonPath;
3113
- const base = serverBaseUrl.replace(/\/+$/, '');
3114
- return `${base}/path${encoded}`;
3115
- }
3116
- function formatProgressEvent(event, serverBaseUrl) {
3117
- switch (event.type) {
3118
- case 'synthesis_start': {
3119
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
3120
- return `🔬 Started meta synthesis: ${dirLink}`;
3121
- }
3122
- case 'phase_start': {
3123
- if (!event.phase) {
3124
- return ' ⚙️ Phase started';
3125
- }
3126
- return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
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;
3127
3118
  }
3119
+ return fsPath;
3120
+ }
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
+ };
3152
+ switch (event.type) {
3153
+ case 'phase_start':
3154
+ return templates.phaseStart(base);
3128
3155
  case 'phase_complete': {
3129
- const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
3130
- const tokenStr = formatTokens(event.tokens);
3131
- const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
3132
- return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
3133
- }
3134
- case 'synthesis_complete': {
3135
- const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
3136
- const tokenStr = formatTokens(event.tokens);
3137
- const duration = event.durationMs !== undefined
3138
- ? formatSeconds(event.durationMs)
3139
- : '0.0s';
3140
- 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 });
3141
3161
  }
3142
3162
  case 'error': {
3143
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
3144
- const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
3145
- const error = event.error ?? 'Unknown error';
3146
- 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 });
3147
3168
  }
3148
- default: {
3169
+ default:
3149
3170
  return 'Unknown progress event';
3150
- }
3151
3171
  }
3152
3172
  }
3153
3173
  class ProgressReporter {
3154
3174
  config;
3155
3175
  logger;
3176
+ templates;
3177
+ /** Snapshot of template source strings used to compile `this.templates`. */
3178
+ lastTemplateSource;
3179
+ serverUrl;
3156
3180
  constructor(config, logger) {
3157
3181
  this.config = config;
3158
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';
3159
3186
  }
3160
3187
  async report(event) {
3161
3188
  // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
@@ -3163,7 +3190,27 @@ class ProgressReporter {
3163
3190
  const target = this.config.reportTarget ?? this.config.reportChannel;
3164
3191
  if (!target)
3165
3192
  return;
3166
- const message = formatProgressEvent(event, this.config.serverBaseUrl);
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
+ }
3167
3214
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
3168
3215
  const args = {
3169
3216
  action: 'send',
@@ -4741,6 +4788,7 @@ function registerStatusRoute(app, deps) {
4741
4788
  critic: emptyPhaseCounts(),
4742
4789
  };
4743
4790
  let nextPhase = null;
4791
+ let metaCounts = null;
4744
4792
  try {
4745
4793
  const metaResult = await cache.get(config, watcher);
4746
4794
  // Count raw phase states (before retry) for display
@@ -4762,6 +4810,17 @@ function registerStatusRoute(app, deps) {
4762
4810
  staleness: winner.effectiveStaleness,
4763
4811
  };
4764
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
+ };
4765
4824
  }
4766
4825
  catch {
4767
4826
  // Watcher unreachable — phase summary unavailable
@@ -4790,6 +4849,7 @@ function registerStatusRoute(app, deps) {
4790
4849
  },
4791
4850
  phaseStateSummary,
4792
4851
  nextPhase,
4852
+ metaCounts,
4793
4853
  };
4794
4854
  },
4795
4855
  });
@@ -5099,11 +5159,10 @@ class HttpWatcherClient {
5099
5159
  async post(endpoint, body) {
5100
5160
  const url = this.baseUrl + endpoint;
5101
5161
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
5102
- const res = await fetch(url, {
5162
+ const res = await fetchWithTimeout(url, this.timeoutMs, {
5103
5163
  method: 'POST',
5104
5164
  headers: { 'Content-Type': 'application/json' },
5105
5165
  body: JSON.stringify(body),
5106
- signal: AbortSignal.timeout(this.timeoutMs),
5107
5166
  });
5108
5167
  if (res.ok) {
5109
5168
  return res.json();
@@ -5145,25 +5204,6 @@ class HttpWatcherClient {
5145
5204
  *
5146
5205
  * @module bootstrap
5147
5206
  */
5148
- /**
5149
- * Compute per-cycle token total from a completed meta.
5150
- *
5151
- * Exported for testing.
5152
- *
5153
- * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5154
- * a value of 1 means architect ran this cycle (was 0 pre-increment),
5155
- * so all three phase token fields are summed. A value \> 1 means architect
5156
- * was skipped (cached brief reused), so only builder + critic are summed.
5157
- */
5158
- function computeCycleTokens(meta) {
5159
- const builderTokens = meta._builderTokens ?? 0;
5160
- const criticTokens = meta._criticTokens ?? 0;
5161
- const architectRan = (meta._synthesisCount ?? 1) === 1;
5162
- const architectTokens = architectRan
5163
- ? (meta._architectTokens ?? 0)
5164
- : 0;
5165
- return architectTokens + builderTokens + criticTokens;
5166
- }
5167
5207
  /**
5168
5208
  * Bootstrap the service: create logger, build server, start listening,
5169
5209
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5240,16 +5280,7 @@ async function startService(config, configPath) {
5240
5280
  // Track whether synthesis_start has been emitted (deferred until
5241
5281
  // a phase actually begins, avoiding orphan "Started" messages
5242
5282
  // when the meta is skipped/locked/fresh). See #165.
5243
- let synthesisStartEmitted = false;
5244
5283
  const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
5245
- // Emit synthesis_start on first phase_start (deferred guard)
5246
- if (evt.type === 'phase_start' && !synthesisStartEmitted) {
5247
- synthesisStartEmitted = true;
5248
- await progress.report({
5249
- type: 'synthesis_start',
5250
- path: ownerPath,
5251
- });
5252
- }
5253
5284
  // Wire current-phase tracking for GET /queue and POST /synthesize/abort
5254
5285
  if (evt.type === 'phase_start' && evt.phase) {
5255
5286
  queue.setCurrentPhase(ownerPath, evt.phase);
@@ -5291,19 +5322,8 @@ async function startService(config, configPath) {
5291
5322
  // Task #9: Reset backoff on ANY successful phase execution
5292
5323
  scheduler.resetBackoff();
5293
5324
  }
5294
- // Emit synthesis_complete only on full-cycle completion
5295
- if (result.cycleComplete) {
5296
- const updatedMeta = result.phaseResult?.updatedMeta;
5297
- const tokens = updatedMeta
5298
- ? computeCycleTokens(updatedMeta)
5299
- : undefined;
5300
- await progress.report({
5301
- type: 'synthesis_complete',
5302
- path: ownerPath,
5303
- durationMs,
5304
- tokens,
5305
- });
5306
- }
5325
+ // Note: synthesis_complete is no longer emitted; the phaseEnd template
5326
+ // for the final critic phase serves as the completion signal (#129).
5307
5327
  }
5308
5328
  catch (err) {
5309
5329
  stats.totalErrors++;
@@ -15,7 +15,7 @@ import type { ServiceConfig } from './schema/config.js';
15
15
  * Shared between the descriptor's `onConfigApply` and the file-watcher
16
16
  * hot-reload in `bootstrap.ts`.
17
17
  */
18
- export declare const RESTART_REQUIRED_FIELDS: readonly ["port", "watcherUrl", "gatewayUrl", "gatewayApiKey", "defaultArchitect", "defaultCritic"];
18
+ export declare const RESTART_REQUIRED_FIELDS: readonly ["port", "watcherUrl", "gatewayUrl", "gatewayApiKey"];
19
19
  interface ConfigHotReloadRuntime {
20
20
  config: ServiceConfig;
21
21
  logger: Logger;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Load and resolve jeeves-meta service config.
3
3
  *
4
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
4
+ * Supports environment-variable substitution (dollar-brace pattern).
5
5
  *
6
6
  * @module configLoader
7
7
  */
@@ -28,8 +28,7 @@ export declare function resolveConfigPath(args: string[]): string;
28
28
  /**
29
29
  * Load service config from a JSON file.
30
30
  *
31
- * Resolves \@file: references for defaultArchitect and defaultCritic,
32
- * and substitutes environment-variable placeholders throughout.
31
+ * Substitutes environment-variable placeholders throughout.
33
32
  *
34
33
  * @param configPath - Path to config JSON file.
35
34
  * @returns Validated ServiceConfig.
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';