@karmaniverous/jeeves-meta 0.13.6 → 0.13.7

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.
@@ -7146,138 +7146,6 @@ function getErrorMessage(err) {
7146
7146
  return err instanceof Error ? err.message : String(err);
7147
7147
  }
7148
7148
 
7149
- /**
7150
- * Factory for a framework-agnostic config apply HTTP handler.
7151
- *
7152
- * @remarks
7153
- * Derives the config file path from the descriptor, validates patches
7154
- * against the descriptor's Zod schema, deep-merges (or replaces),
7155
- * writes atomically, and calls the optional `onConfigApply` callback.
7156
- */
7157
- /**
7158
- * Deep-merge two plain objects. Arrays and non-objects are replaced.
7159
- *
7160
- * @param target - Base object.
7161
- * @param source - Object to merge on top.
7162
- * @returns A new merged object.
7163
- */
7164
- function deepMerge(target, source) {
7165
- const result = { ...target };
7166
- for (const key of Object.keys(source)) {
7167
- const tVal = target[key];
7168
- const sVal = source[key];
7169
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
7170
- result[key] = deepMerge(tVal, sVal);
7171
- }
7172
- else {
7173
- result[key] = sVal;
7174
- }
7175
- }
7176
- return result;
7177
- }
7178
- /**
7179
- * Check if a value is a plain object (not null, not an array).
7180
- *
7181
- * @param val - Value to check.
7182
- * @returns True if the value is a plain object.
7183
- */
7184
- function isPlainObject(val) {
7185
- return typeof val === 'object' && val !== null && !Array.isArray(val);
7186
- }
7187
- /**
7188
- * Read and parse a JSON config file.
7189
- *
7190
- * @param filePath - Absolute path to the file.
7191
- * @returns Parsed object or empty object if not found.
7192
- */
7193
- function readConfigFile(filePath) {
7194
- if (!existsSync(filePath))
7195
- return {};
7196
- try {
7197
- const raw = readFileSync(filePath, 'utf-8');
7198
- return JSON.parse(raw);
7199
- }
7200
- catch (err) {
7201
- console.warn(`jeeves-core: Could not read config file ${filePath}: ${getErrorMessage(err)}`);
7202
- return {};
7203
- }
7204
- }
7205
- /**
7206
- * Create a framework-agnostic config apply handler.
7207
- *
7208
- * @remarks
7209
- * The handler:
7210
- * 1. Reads existing config from `{configRoot}/jeeves-{name}/{configFileName}`
7211
- * 2. Deep-merges the patch (or replaces if `replace: true`)
7212
- * 3. Validates the merged result against `descriptor.configSchema`
7213
- * 4. Writes atomically
7214
- * 5. Calls `descriptor.onConfigApply` with the merged config (if defined)
7215
- *
7216
- * @param descriptor - The component descriptor.
7217
- * @returns An async handler returning `{ status, body }`.
7218
- */
7219
- function createConfigApplyHandler(descriptor) {
7220
- return async (request) => {
7221
- const { patch, replace } = request;
7222
- // Derive config path
7223
- const configDir = getComponentConfigDir(descriptor.name);
7224
- const configPath = join(configDir, descriptor.configFileName);
7225
- // Read existing config
7226
- const existing = readConfigFile(configPath);
7227
- // Merge or replace
7228
- const mergeFn = descriptor.customMerge ?? deepMerge;
7229
- const merged = replace ? { ...patch } : mergeFn(existing, patch);
7230
- // Validate against schema
7231
- const schema = descriptor.configSchema;
7232
- const parseResult = schema.safeParse(merged);
7233
- if (!parseResult.success) {
7234
- return {
7235
- status: 400,
7236
- body: {
7237
- error: 'Config validation failed',
7238
- issues: parseResult.error.issues,
7239
- },
7240
- };
7241
- }
7242
- // Extract validated data (Zod returns unknown from ZodTypeAny)
7243
- const validatedConfig = parseResult.data;
7244
- // Write atomically
7245
- try {
7246
- const json = JSON.stringify(validatedConfig, null, 2) + '\n';
7247
- atomicWrite(configPath, json);
7248
- }
7249
- catch (err) {
7250
- return {
7251
- status: 500,
7252
- body: { error: `Failed to write config: ${getErrorMessage(err)}` },
7253
- };
7254
- }
7255
- // Call onConfigApply callback if defined
7256
- if (descriptor.onConfigApply) {
7257
- try {
7258
- await descriptor.onConfigApply(validatedConfig);
7259
- }
7260
- catch (err) {
7261
- return {
7262
- status: 200,
7263
- body: {
7264
- applied: true,
7265
- warning: `Config written but callback failed: ${getErrorMessage(err)}`,
7266
- config: validatedConfig,
7267
- },
7268
- };
7269
- }
7270
- }
7271
- return {
7272
- status: 200,
7273
- body: {
7274
- applied: true,
7275
- config: validatedConfig,
7276
- },
7277
- };
7278
- };
7279
- }
7280
-
7281
7149
  /**
7282
7150
  * Generic config query handler with JSONPath support.
7283
7151
  *
@@ -8581,8 +8449,10 @@ const serviceConfigSchema = metaConfigSchema.extend({
8581
8449
  port: z.number().int().min(1).max(65535).default(1938),
8582
8450
  /** Cron schedule for synthesis cycles (default: every 30 min). */
8583
8451
  schedule: z.string().default('*/30 * * * *'),
8584
- /** Optional channel identifier for reporting. */
8452
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8585
8453
  reportChannel: z.string().optional(),
8454
+ /** Channel/user ID to send progress messages to. */
8455
+ reportTarget: z.string().optional(),
8586
8456
  /** Optional base URL for the service, used to construct entity links in progress reports. */
8587
8457
  serverBaseUrl: z.string().optional(),
8588
8458
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
@@ -10964,6 +10834,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
10964
10834
  function formatNumber(n) {
10965
10835
  return n.toLocaleString('en-US');
10966
10836
  }
10837
+ function formatTokens(tokens) {
10838
+ return tokens !== undefined
10839
+ ? formatNumber(tokens) + ' tokens'
10840
+ : 'unknown tokens';
10841
+ }
10967
10842
  function formatSeconds(durationMs) {
10968
10843
  const seconds = durationMs / 1000;
10969
10844
  return Math.round(seconds).toString() + 's';
@@ -11014,17 +10889,17 @@ function formatProgressEvent(event, serverBaseUrl) {
11014
10889
  }
11015
10890
  case 'phase_complete': {
11016
10891
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
11017
- const tokens = event.tokens ?? 0;
10892
+ const tokenStr = formatTokens(event.tokens);
11018
10893
  const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
11019
- return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
10894
+ return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
11020
10895
  }
11021
10896
  case 'synthesis_complete': {
11022
10897
  const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
11023
- const tokens = event.tokens ?? 0;
10898
+ const tokenStr = formatTokens(event.tokens);
11024
10899
  const duration = event.durationMs !== undefined
11025
10900
  ? formatSeconds(event.durationMs)
11026
10901
  : '0.0s';
11027
- return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
10902
+ return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
11028
10903
  }
11029
10904
  case 'error': {
11030
10905
  const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
@@ -11045,19 +10920,23 @@ class ProgressReporter {
11045
10920
  this.logger = logger;
11046
10921
  }
11047
10922
  async report(event) {
11048
- const target = this.config.reportChannel;
10923
+ // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
10924
+ // Legacy mode: reportChannel alone acts as the target (backward compatible).
10925
+ const target = this.config.reportTarget ?? this.config.reportChannel;
11049
10926
  if (!target)
11050
10927
  return;
11051
10928
  const message = formatProgressEvent(event, this.config.serverBaseUrl);
11052
10929
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
11053
- const payload = {
11054
- tool: 'message',
11055
- args: {
11056
- action: 'send',
11057
- target,
11058
- message,
11059
- },
10930
+ const args = {
10931
+ action: 'send',
10932
+ target,
10933
+ message,
11060
10934
  };
10935
+ // Include channel field only in multi-channel mode (reportTarget is set)
10936
+ if (this.config.reportTarget && this.config.reportChannel) {
10937
+ args.channel = this.config.reportChannel;
10938
+ }
10939
+ const payload = { tool: 'message', args };
11061
10940
  try {
11062
10941
  const res = await fetch(url, {
11063
10942
  method: 'POST',
@@ -11662,14 +11541,19 @@ function metaExists(ownerPath) {
11662
11541
  * Walk returns file paths; we need the unique set of immediate parent
11663
11542
  * directories that could be owners.
11664
11543
  */
11665
- function extractDirectories(filePaths) {
11544
+ function extractDirectories(filePaths, logger) {
11666
11545
  const dirs = new Set();
11667
11546
  for (const fp of filePaths) {
11668
- const dir = posix.dirname(fp);
11547
+ // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11548
+ const normalized = normalizePath(fp);
11549
+ const dir = posix.dirname(normalized);
11669
11550
  if (dir !== '.' && dir !== '/') {
11670
11551
  dirs.add(dir);
11671
11552
  }
11672
11553
  }
11554
+ if (filePaths.length > 0 && dirs.size === 0) {
11555
+ logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
11556
+ }
11673
11557
  return [...dirs];
11674
11558
  }
11675
11559
  /**
@@ -11687,7 +11571,7 @@ async function autoSeedPass(rules, watcher, logger) {
11687
11571
  const candidates = new Map();
11688
11572
  for (const rule of rules) {
11689
11573
  const files = await watcher.walk([rule.match]);
11690
- const dirs = extractDirectories(files);
11574
+ const dirs = extractDirectories(files, logger);
11691
11575
  for (const dir of dirs) {
11692
11576
  candidates.set(dir, {
11693
11577
  steer: rule.steer,
@@ -11905,16 +11789,97 @@ function registerConfigRoute(app, deps) {
11905
11789
  }
11906
11790
 
11907
11791
  /**
11908
- * POST /config/apply — apply a config patch via the core SDK handler.
11792
+ * POST /config/apply — apply a config patch using the runtime config path.
11793
+ *
11794
+ * The core SDK's `createConfigApplyHandler` derives the config path from
11795
+ * `getComponentConfigDir()` which uses the npm global config root. This
11796
+ * local implementation uses the actual runtime config path instead, so
11797
+ * temp files are written alongside the active config file.
11909
11798
  *
11910
11799
  * @module routes/configApply
11911
11800
  */
11912
11801
  /** Register the POST /config/apply route. */
11913
- function registerConfigApplyRoute(app) {
11914
- const handler = createConfigApplyHandler(metaDescriptor);
11802
+ function registerConfigApplyRoute(app, configPath) {
11915
11803
  app.post('/config/apply', async (request, reply) => {
11916
- const result = await handler(request.body);
11917
- return reply.status(result.status).send(result.body);
11804
+ if (!configPath) {
11805
+ return reply
11806
+ .status(500)
11807
+ .send({ error: 'No runtime config path available' });
11808
+ }
11809
+ // Validate request body
11810
+ const body = request.body;
11811
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
11812
+ return reply
11813
+ .status(400)
11814
+ .send({ error: 'Request body must be a JSON object' });
11815
+ }
11816
+ const { patch, replace } = body;
11817
+ if (patch === null ||
11818
+ patch === undefined ||
11819
+ typeof patch !== 'object' ||
11820
+ Array.isArray(patch)) {
11821
+ return reply
11822
+ .status(400)
11823
+ .send({ error: '`patch` must be a non-null object' });
11824
+ }
11825
+ if (replace !== undefined && typeof replace !== 'boolean') {
11826
+ return reply
11827
+ .status(400)
11828
+ .send({ error: '`replace` must be a boolean if provided' });
11829
+ }
11830
+ // Read existing config from the runtime config path
11831
+ let existing = {};
11832
+ try {
11833
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
11834
+ }
11835
+ catch (err) {
11836
+ if (err instanceof SyntaxError ||
11837
+ (err instanceof Error && err.message.includes('JSON'))) {
11838
+ return reply.status(400).send({
11839
+ error: `Existing config file contains invalid JSON: ${err.message}`,
11840
+ });
11841
+ }
11842
+ // File missing — start from empty
11843
+ }
11844
+ // Merge or replace
11845
+ const merged = replace
11846
+ ? { ...patch }
11847
+ : { ...existing, ...patch };
11848
+ // Validate against schema
11849
+ const parseResult = serviceConfigSchema.safeParse(merged);
11850
+ if (!parseResult.success) {
11851
+ return reply.status(400).send({
11852
+ error: 'Config validation failed',
11853
+ issues: parseResult.error.issues,
11854
+ });
11855
+ }
11856
+ const validatedConfig = parseResult.data;
11857
+ // Write atomically — temp file lands next to the runtime config file
11858
+ try {
11859
+ const json = JSON.stringify(validatedConfig, null, 2) + '\n';
11860
+ atomicWrite(configPath, json);
11861
+ }
11862
+ catch (err) {
11863
+ const message = err instanceof Error ? err.message : String(err);
11864
+ return reply
11865
+ .status(500)
11866
+ .send({ error: `Failed to write config: ${message}` });
11867
+ }
11868
+ // Apply hot-reload callback
11869
+ try {
11870
+ applyHotReloadedConfig(validatedConfig);
11871
+ }
11872
+ catch (err) {
11873
+ const message = err instanceof Error ? err.message : String(err);
11874
+ return reply.status(200).send({
11875
+ applied: true,
11876
+ warning: `Config written but callback failed: ${message}`,
11877
+ restartRequired: RESTART_REQUIRED_FIELDS,
11878
+ });
11879
+ }
11880
+ return reply.status(200).send({
11881
+ applied: true,
11882
+ });
11918
11883
  });
11919
11884
  }
11920
11885
 
@@ -12348,6 +12313,16 @@ async function checkWatcher(url) {
12348
12313
  return { url, status: 'unreachable', checkedAt };
12349
12314
  }
12350
12315
  }
12316
+ /** Derive service-specific state from current activity and lifecycle. */
12317
+ function deriveServiceState(deps) {
12318
+ if (deps.shuttingDown)
12319
+ return 'stopping';
12320
+ if (deps.queue.current)
12321
+ return 'synthesizing';
12322
+ if (deps.queue.depth > 0)
12323
+ return 'waiting';
12324
+ return 'idle';
12325
+ }
12351
12326
  function registerStatusRoute(app, deps) {
12352
12327
  const statusHandler = createStatusHandler({
12353
12328
  name: SERVICE_NAME,
@@ -12360,6 +12335,7 @@ function registerStatusRoute(app, deps) {
12360
12335
  checkDependency(config.gatewayUrl, '/status'),
12361
12336
  ]);
12362
12337
  return {
12338
+ serviceState: deriveServiceState(deps),
12363
12339
  currentTarget: queue.current?.path ?? null,
12364
12340
  queue: queue.getState(),
12365
12341
  stats: {
@@ -12503,7 +12479,7 @@ function registerRoutes(app, deps) {
12503
12479
  registerSeedRoute(app, deps);
12504
12480
  registerUnlockRoute(app, deps);
12505
12481
  registerConfigRoute(app, deps);
12506
- registerConfigApplyRoute(app);
12482
+ registerConfigApplyRoute(app, deps.configPath);
12507
12483
  registerQueueRoutes(app, deps);
12508
12484
  }
12509
12485
 
@@ -12707,6 +12683,7 @@ async function startService(config, configPath) {
12707
12683
  scheduler,
12708
12684
  stats,
12709
12685
  executor,
12686
+ configPath,
12710
12687
  };
12711
12688
  registerConfigHotReloadRuntime({
12712
12689
  config,
@@ -12742,9 +12719,17 @@ async function startService(config, configPath) {
12742
12719
  try {
12743
12720
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
12744
12721
  // Track token stats from phase completions
12745
- if (evt.type === 'phase_complete' && evt.tokens) {
12746
- stats.totalTokens += evt.tokens;
12747
- cycleTokens += evt.tokens;
12722
+ if (evt.type === 'phase_complete') {
12723
+ if (evt.tokens !== undefined) {
12724
+ stats.totalTokens += evt.tokens;
12725
+ if (cycleTokens !== undefined) {
12726
+ cycleTokens += evt.tokens;
12727
+ }
12728
+ }
12729
+ else {
12730
+ cycleTokens = undefined;
12731
+ logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12732
+ }
12748
12733
  }
12749
12734
  await progress.report(evt);
12750
12735
  }, logger);
package/dist/index.d.ts CHANGED
@@ -239,6 +239,7 @@ declare const serviceConfigSchema: z.ZodObject<{
239
239
  port: z.ZodDefault<z.ZodNumber>;
240
240
  schedule: z.ZodDefault<z.ZodString>;
241
241
  reportChannel: z.ZodOptional<z.ZodString>;
242
+ reportTarget: z.ZodOptional<z.ZodString>;
242
243
  serverBaseUrl: z.ZodOptional<z.ZodString>;
243
244
  watcherHealthIntervalMs: z.ZodDefault<z.ZodNumber>;
244
245
  logging: z.ZodDefault<z.ZodObject<{
@@ -1248,8 +1249,15 @@ type ProgressEvent = {
1248
1249
  type ProgressReporterConfig = {
1249
1250
  gatewayUrl: string;
1250
1251
  gatewayApiKey?: string;
1251
- /** Gateway channel target (platform-agnostic). If unset, reporting is disabled. */
1252
+ /**
1253
+ * Messaging channel name (e.g. 'slack'). When set alongside reportTarget,
1254
+ * included in the gateway message payload as `channel`.
1255
+ * Legacy: if reportTarget is unset, reportChannel is used as the target
1256
+ * (single-channel mode, backward compatible).
1257
+ */
1252
1258
  reportChannel?: string;
1259
+ /** Channel/user ID to send messages to. Takes priority over reportChannel as target. */
1260
+ reportTarget?: string;
1253
1261
  /** Optional base URL for the service, used to construct entity links. */
1254
1262
  serverBaseUrl?: string;
1255
1263
  };
@@ -1429,6 +1437,8 @@ interface RouteDeps {
1429
1437
  executor?: Pick<GatewayExecutor, 'abort'>;
1430
1438
  /** Set to true during graceful shutdown. */
1431
1439
  shuttingDown?: boolean;
1440
+ /** Runtime config file path for config-apply. */
1441
+ configPath?: string;
1432
1442
  }
1433
1443
  /** Register all HTTP routes on the Fastify instance. */
1434
1444
  declare function registerRoutes(app: FastifyInstance, deps: RouteDeps): void;
package/dist/index.js CHANGED
@@ -7345,138 +7345,6 @@ function getErrorMessage(err) {
7345
7345
  return err instanceof Error ? err.message : String(err);
7346
7346
  }
7347
7347
 
7348
- /**
7349
- * Factory for a framework-agnostic config apply HTTP handler.
7350
- *
7351
- * @remarks
7352
- * Derives the config file path from the descriptor, validates patches
7353
- * against the descriptor's Zod schema, deep-merges (or replaces),
7354
- * writes atomically, and calls the optional `onConfigApply` callback.
7355
- */
7356
- /**
7357
- * Deep-merge two plain objects. Arrays and non-objects are replaced.
7358
- *
7359
- * @param target - Base object.
7360
- * @param source - Object to merge on top.
7361
- * @returns A new merged object.
7362
- */
7363
- function deepMerge(target, source) {
7364
- const result = { ...target };
7365
- for (const key of Object.keys(source)) {
7366
- const tVal = target[key];
7367
- const sVal = source[key];
7368
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
7369
- result[key] = deepMerge(tVal, sVal);
7370
- }
7371
- else {
7372
- result[key] = sVal;
7373
- }
7374
- }
7375
- return result;
7376
- }
7377
- /**
7378
- * Check if a value is a plain object (not null, not an array).
7379
- *
7380
- * @param val - Value to check.
7381
- * @returns True if the value is a plain object.
7382
- */
7383
- function isPlainObject(val) {
7384
- return typeof val === 'object' && val !== null && !Array.isArray(val);
7385
- }
7386
- /**
7387
- * Read and parse a JSON config file.
7388
- *
7389
- * @param filePath - Absolute path to the file.
7390
- * @returns Parsed object or empty object if not found.
7391
- */
7392
- function readConfigFile(filePath) {
7393
- if (!existsSync(filePath))
7394
- return {};
7395
- try {
7396
- const raw = readFileSync(filePath, 'utf-8');
7397
- return JSON.parse(raw);
7398
- }
7399
- catch (err) {
7400
- console.warn(`jeeves-core: Could not read config file ${filePath}: ${getErrorMessage(err)}`);
7401
- return {};
7402
- }
7403
- }
7404
- /**
7405
- * Create a framework-agnostic config apply handler.
7406
- *
7407
- * @remarks
7408
- * The handler:
7409
- * 1. Reads existing config from `{configRoot}/jeeves-{name}/{configFileName}`
7410
- * 2. Deep-merges the patch (or replaces if `replace: true`)
7411
- * 3. Validates the merged result against `descriptor.configSchema`
7412
- * 4. Writes atomically
7413
- * 5. Calls `descriptor.onConfigApply` with the merged config (if defined)
7414
- *
7415
- * @param descriptor - The component descriptor.
7416
- * @returns An async handler returning `{ status, body }`.
7417
- */
7418
- function createConfigApplyHandler(descriptor) {
7419
- return async (request) => {
7420
- const { patch, replace } = request;
7421
- // Derive config path
7422
- const configDir = getComponentConfigDir(descriptor.name);
7423
- const configPath = join(configDir, descriptor.configFileName);
7424
- // Read existing config
7425
- const existing = readConfigFile(configPath);
7426
- // Merge or replace
7427
- const mergeFn = descriptor.customMerge ?? deepMerge;
7428
- const merged = replace ? { ...patch } : mergeFn(existing, patch);
7429
- // Validate against schema
7430
- const schema = descriptor.configSchema;
7431
- const parseResult = schema.safeParse(merged);
7432
- if (!parseResult.success) {
7433
- return {
7434
- status: 400,
7435
- body: {
7436
- error: 'Config validation failed',
7437
- issues: parseResult.error.issues,
7438
- },
7439
- };
7440
- }
7441
- // Extract validated data (Zod returns unknown from ZodTypeAny)
7442
- const validatedConfig = parseResult.data;
7443
- // Write atomically
7444
- try {
7445
- const json = JSON.stringify(validatedConfig, null, 2) + '\n';
7446
- atomicWrite(configPath, json);
7447
- }
7448
- catch (err) {
7449
- return {
7450
- status: 500,
7451
- body: { error: `Failed to write config: ${getErrorMessage(err)}` },
7452
- };
7453
- }
7454
- // Call onConfigApply callback if defined
7455
- if (descriptor.onConfigApply) {
7456
- try {
7457
- await descriptor.onConfigApply(validatedConfig);
7458
- }
7459
- catch (err) {
7460
- return {
7461
- status: 200,
7462
- body: {
7463
- applied: true,
7464
- warning: `Config written but callback failed: ${getErrorMessage(err)}`,
7465
- config: validatedConfig,
7466
- },
7467
- };
7468
- }
7469
- }
7470
- return {
7471
- status: 200,
7472
- body: {
7473
- applied: true,
7474
- config: validatedConfig,
7475
- },
7476
- };
7477
- };
7478
- }
7479
-
7480
7348
  /**
7481
7349
  * Generic config query handler with JSONPath support.
7482
7350
  *
@@ -8274,8 +8142,10 @@ const serviceConfigSchema = metaConfigSchema.extend({
8274
8142
  port: z.number().int().min(1).max(65535).default(1938),
8275
8143
  /** Cron schedule for synthesis cycles (default: every 30 min). */
8276
8144
  schedule: z.string().default('*/30 * * * *'),
8277
- /** Optional channel identifier for reporting. */
8145
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8278
8146
  reportChannel: z.string().optional(),
8147
+ /** Channel/user ID to send progress messages to. */
8148
+ reportTarget: z.string().optional(),
8279
8149
  /** Optional base URL for the service, used to construct entity links in progress reports. */
8280
8150
  serverBaseUrl: z.string().optional(),
8281
8151
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
@@ -10495,6 +10365,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
10495
10365
  function formatNumber(n) {
10496
10366
  return n.toLocaleString('en-US');
10497
10367
  }
10368
+ function formatTokens(tokens) {
10369
+ return tokens !== undefined
10370
+ ? formatNumber(tokens) + ' tokens'
10371
+ : 'unknown tokens';
10372
+ }
10498
10373
  function formatSeconds(durationMs) {
10499
10374
  const seconds = durationMs / 1000;
10500
10375
  return Math.round(seconds).toString() + 's';
@@ -10545,17 +10420,17 @@ function formatProgressEvent(event, serverBaseUrl) {
10545
10420
  }
10546
10421
  case 'phase_complete': {
10547
10422
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
10548
- const tokens = event.tokens ?? 0;
10423
+ const tokenStr = formatTokens(event.tokens);
10549
10424
  const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
10550
- return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
10425
+ return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
10551
10426
  }
10552
10427
  case 'synthesis_complete': {
10553
10428
  const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
10554
- const tokens = event.tokens ?? 0;
10429
+ const tokenStr = formatTokens(event.tokens);
10555
10430
  const duration = event.durationMs !== undefined
10556
10431
  ? formatSeconds(event.durationMs)
10557
10432
  : '0.0s';
10558
- return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
10433
+ return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
10559
10434
  }
10560
10435
  case 'error': {
10561
10436
  const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
@@ -10576,19 +10451,23 @@ class ProgressReporter {
10576
10451
  this.logger = logger;
10577
10452
  }
10578
10453
  async report(event) {
10579
- const target = this.config.reportChannel;
10454
+ // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
10455
+ // Legacy mode: reportChannel alone acts as the target (backward compatible).
10456
+ const target = this.config.reportTarget ?? this.config.reportChannel;
10580
10457
  if (!target)
10581
10458
  return;
10582
10459
  const message = formatProgressEvent(event, this.config.serverBaseUrl);
10583
10460
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
10584
- const payload = {
10585
- tool: 'message',
10586
- args: {
10587
- action: 'send',
10588
- target,
10589
- message,
10590
- },
10461
+ const args = {
10462
+ action: 'send',
10463
+ target,
10464
+ message,
10591
10465
  };
10466
+ // Include channel field only in multi-channel mode (reportTarget is set)
10467
+ if (this.config.reportTarget && this.config.reportChannel) {
10468
+ args.channel = this.config.reportChannel;
10469
+ }
10470
+ const payload = { tool: 'message', args };
10592
10471
  try {
10593
10472
  const res = await fetch(url, {
10594
10473
  method: 'POST',
@@ -11193,14 +11072,19 @@ function metaExists(ownerPath) {
11193
11072
  * Walk returns file paths; we need the unique set of immediate parent
11194
11073
  * directories that could be owners.
11195
11074
  */
11196
- function extractDirectories(filePaths) {
11075
+ function extractDirectories(filePaths, logger) {
11197
11076
  const dirs = new Set();
11198
11077
  for (const fp of filePaths) {
11199
- const dir = posix.dirname(fp);
11078
+ // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11079
+ const normalized = normalizePath(fp);
11080
+ const dir = posix.dirname(normalized);
11200
11081
  if (dir !== '.' && dir !== '/') {
11201
11082
  dirs.add(dir);
11202
11083
  }
11203
11084
  }
11085
+ if (filePaths.length > 0 && dirs.size === 0) {
11086
+ logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
11087
+ }
11204
11088
  return [...dirs];
11205
11089
  }
11206
11090
  /**
@@ -11218,7 +11102,7 @@ async function autoSeedPass(rules, watcher, logger) {
11218
11102
  const candidates = new Map();
11219
11103
  for (const rule of rules) {
11220
11104
  const files = await watcher.walk([rule.match]);
11221
- const dirs = extractDirectories(files);
11105
+ const dirs = extractDirectories(files, logger);
11222
11106
  for (const dir of dirs) {
11223
11107
  candidates.set(dir, {
11224
11108
  steer: rule.steer,
@@ -11436,16 +11320,97 @@ function registerConfigRoute(app, deps) {
11436
11320
  }
11437
11321
 
11438
11322
  /**
11439
- * POST /config/apply — apply a config patch via the core SDK handler.
11323
+ * POST /config/apply — apply a config patch using the runtime config path.
11324
+ *
11325
+ * The core SDK's `createConfigApplyHandler` derives the config path from
11326
+ * `getComponentConfigDir()` which uses the npm global config root. This
11327
+ * local implementation uses the actual runtime config path instead, so
11328
+ * temp files are written alongside the active config file.
11440
11329
  *
11441
11330
  * @module routes/configApply
11442
11331
  */
11443
11332
  /** Register the POST /config/apply route. */
11444
- function registerConfigApplyRoute(app) {
11445
- const handler = createConfigApplyHandler(metaDescriptor);
11333
+ function registerConfigApplyRoute(app, configPath) {
11446
11334
  app.post('/config/apply', async (request, reply) => {
11447
- const result = await handler(request.body);
11448
- return reply.status(result.status).send(result.body);
11335
+ if (!configPath) {
11336
+ return reply
11337
+ .status(500)
11338
+ .send({ error: 'No runtime config path available' });
11339
+ }
11340
+ // Validate request body
11341
+ const body = request.body;
11342
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
11343
+ return reply
11344
+ .status(400)
11345
+ .send({ error: 'Request body must be a JSON object' });
11346
+ }
11347
+ const { patch, replace } = body;
11348
+ if (patch === null ||
11349
+ patch === undefined ||
11350
+ typeof patch !== 'object' ||
11351
+ Array.isArray(patch)) {
11352
+ return reply
11353
+ .status(400)
11354
+ .send({ error: '`patch` must be a non-null object' });
11355
+ }
11356
+ if (replace !== undefined && typeof replace !== 'boolean') {
11357
+ return reply
11358
+ .status(400)
11359
+ .send({ error: '`replace` must be a boolean if provided' });
11360
+ }
11361
+ // Read existing config from the runtime config path
11362
+ let existing = {};
11363
+ try {
11364
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
11365
+ }
11366
+ catch (err) {
11367
+ if (err instanceof SyntaxError ||
11368
+ (err instanceof Error && err.message.includes('JSON'))) {
11369
+ return reply.status(400).send({
11370
+ error: `Existing config file contains invalid JSON: ${err.message}`,
11371
+ });
11372
+ }
11373
+ // File missing — start from empty
11374
+ }
11375
+ // Merge or replace
11376
+ const merged = replace
11377
+ ? { ...patch }
11378
+ : { ...existing, ...patch };
11379
+ // Validate against schema
11380
+ const parseResult = serviceConfigSchema.safeParse(merged);
11381
+ if (!parseResult.success) {
11382
+ return reply.status(400).send({
11383
+ error: 'Config validation failed',
11384
+ issues: parseResult.error.issues,
11385
+ });
11386
+ }
11387
+ const validatedConfig = parseResult.data;
11388
+ // Write atomically — temp file lands next to the runtime config file
11389
+ try {
11390
+ const json = JSON.stringify(validatedConfig, null, 2) + '\n';
11391
+ atomicWrite(configPath, json);
11392
+ }
11393
+ catch (err) {
11394
+ const message = err instanceof Error ? err.message : String(err);
11395
+ return reply
11396
+ .status(500)
11397
+ .send({ error: `Failed to write config: ${message}` });
11398
+ }
11399
+ // Apply hot-reload callback
11400
+ try {
11401
+ applyHotReloadedConfig(validatedConfig);
11402
+ }
11403
+ catch (err) {
11404
+ const message = err instanceof Error ? err.message : String(err);
11405
+ return reply.status(200).send({
11406
+ applied: true,
11407
+ warning: `Config written but callback failed: ${message}`,
11408
+ restartRequired: RESTART_REQUIRED_FIELDS,
11409
+ });
11410
+ }
11411
+ return reply.status(200).send({
11412
+ applied: true,
11413
+ });
11449
11414
  });
11450
11415
  }
11451
11416
 
@@ -11851,6 +11816,16 @@ async function checkWatcher(url) {
11851
11816
  return { url, status: 'unreachable', checkedAt };
11852
11817
  }
11853
11818
  }
11819
+ /** Derive service-specific state from current activity and lifecycle. */
11820
+ function deriveServiceState(deps) {
11821
+ if (deps.shuttingDown)
11822
+ return 'stopping';
11823
+ if (deps.queue.current)
11824
+ return 'synthesizing';
11825
+ if (deps.queue.depth > 0)
11826
+ return 'waiting';
11827
+ return 'idle';
11828
+ }
11854
11829
  function registerStatusRoute(app, deps) {
11855
11830
  const statusHandler = createStatusHandler({
11856
11831
  name: SERVICE_NAME,
@@ -11863,6 +11838,7 @@ function registerStatusRoute(app, deps) {
11863
11838
  checkDependency(config.gatewayUrl, '/status'),
11864
11839
  ]);
11865
11840
  return {
11841
+ serviceState: deriveServiceState(deps),
11866
11842
  currentTarget: queue.current?.path ?? null,
11867
11843
  queue: queue.getState(),
11868
11844
  stats: {
@@ -12006,7 +11982,7 @@ function registerRoutes(app, deps) {
12006
11982
  registerSeedRoute(app, deps);
12007
11983
  registerUnlockRoute(app, deps);
12008
11984
  registerConfigRoute(app, deps);
12009
- registerConfigApplyRoute(app);
11985
+ registerConfigApplyRoute(app, deps.configPath);
12010
11986
  registerQueueRoutes(app, deps);
12011
11987
  }
12012
11988
 
@@ -12210,6 +12186,7 @@ async function startService(config, configPath) {
12210
12186
  scheduler,
12211
12187
  stats,
12212
12188
  executor,
12189
+ configPath,
12213
12190
  };
12214
12191
  registerConfigHotReloadRuntime({
12215
12192
  config,
@@ -12245,9 +12222,17 @@ async function startService(config, configPath) {
12245
12222
  try {
12246
12223
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
12247
12224
  // Track token stats from phase completions
12248
- if (evt.type === 'phase_complete' && evt.tokens) {
12249
- stats.totalTokens += evt.tokens;
12250
- cycleTokens += evt.tokens;
12225
+ if (evt.type === 'phase_complete') {
12226
+ if (evt.tokens !== undefined) {
12227
+ stats.totalTokens += evt.tokens;
12228
+ if (cycleTokens !== undefined) {
12229
+ cycleTokens += evt.tokens;
12230
+ }
12231
+ }
12232
+ else {
12233
+ cycleTokens = undefined;
12234
+ logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12235
+ }
12251
12236
  }
12252
12237
  await progress.report(evt);
12253
12238
  }, logger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-meta",
3
- "version": "0.13.6",
3
+ "version": "0.13.7",
4
4
  "author": "Jason Williscroft",
5
5
  "description": "Fastify HTTP service for the Jeeves Meta synthesis engine",
6
6
  "license": "BSD-3-Clause",