@karmaniverous/jeeves-meta 0.13.6 → 0.13.8

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. */
@@ -9405,17 +9275,20 @@ class GatewayExecutor {
9405
9275
  }
9406
9276
  }
9407
9277
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
9408
- async invoke(tool, args) {
9278
+ async invoke(tool, args, sessionKey) {
9409
9279
  const headers = {
9410
9280
  'Content-Type': 'application/json',
9411
9281
  };
9412
9282
  if (this.apiKey) {
9413
9283
  headers['Authorization'] = 'Bearer ' + this.apiKey;
9414
9284
  }
9285
+ const body = { tool, args };
9286
+ if (sessionKey)
9287
+ body.sessionKey = sessionKey;
9415
9288
  const res = await fetch(this.gatewayUrl + '/tools/invoke', {
9416
9289
  method: 'POST',
9417
9290
  headers,
9418
- body: JSON.stringify({ tool, args }),
9291
+ body: JSON.stringify(body),
9419
9292
  });
9420
9293
  if (!res.ok) {
9421
9294
  const text = await res.text();
@@ -9428,12 +9301,12 @@ class GatewayExecutor {
9428
9301
  return data;
9429
9302
  }
9430
9303
  /** Look up totalTokens for a session via sessions_list. */
9431
- async getSessionTokens(sessionKey) {
9304
+ async getSessionTokens(sessionKey, invokeSessionKey) {
9432
9305
  try {
9433
9306
  const result = await this.invoke('sessions_list', {
9434
9307
  limit: 20,
9435
9308
  messageLimit: 0,
9436
- });
9309
+ }, invokeSessionKey);
9437
9310
  const sessions = (result.result?.details?.sessions ??
9438
9311
  result.result?.sessions ??
9439
9312
  []);
@@ -9461,6 +9334,7 @@ class GatewayExecutor {
9461
9334
  // Generate unique output path for file-based output
9462
9335
  const outputId = randomUUID();
9463
9336
  const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
9337
+ const invokeSessionKey = 'agent:main:meta-invoke:' + outputId;
9464
9338
  // Append file output instruction to the task
9465
9339
  const taskWithOutput = task +
9466
9340
  '\n\n## OUTPUT DELIVERY\n\n' +
@@ -9478,7 +9352,7 @@ class GatewayExecutor {
9478
9352
  runTimeoutSeconds: timeoutSeconds,
9479
9353
  ...(options?.thinking ? { thinking: options.thinking } : {}),
9480
9354
  ...(options?.model ? { model: options.model } : {}),
9481
- });
9355
+ }, invokeSessionKey);
9482
9356
  const details = (spawnResult.result?.details ?? spawnResult.result);
9483
9357
  const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9484
9358
  if (typeof sessionKey !== 'string' || !sessionKey) {
@@ -9498,7 +9372,7 @@ class GatewayExecutor {
9498
9372
  sessionKey,
9499
9373
  limit: 5,
9500
9374
  includeTools: false,
9501
- });
9375
+ }, invokeSessionKey);
9502
9376
  const messages = historyResult.result?.details?.messages ??
9503
9377
  historyResult.result?.messages ??
9504
9378
  [];
@@ -9511,7 +9385,7 @@ class GatewayExecutor {
9511
9385
  lastMsg.stopReason !== 'toolUse' &&
9512
9386
  lastMsg.stopReason !== 'error') {
9513
9387
  // Fetch token usage from session metadata
9514
- const tokens = await this.getSessionTokens(sessionKey);
9388
+ const tokens = await this.getSessionTokens(sessionKey, invokeSessionKey);
9515
9389
  // Read output from file (sub-agent wrote it via Write tool)
9516
9390
  if (existsSync(outputPath)) {
9517
9391
  try {
@@ -10964,6 +10838,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
10964
10838
  function formatNumber(n) {
10965
10839
  return n.toLocaleString('en-US');
10966
10840
  }
10841
+ function formatTokens(tokens) {
10842
+ return tokens !== undefined
10843
+ ? formatNumber(tokens) + ' tokens'
10844
+ : 'unknown tokens';
10845
+ }
10967
10846
  function formatSeconds(durationMs) {
10968
10847
  const seconds = durationMs / 1000;
10969
10848
  return Math.round(seconds).toString() + 's';
@@ -11014,17 +10893,17 @@ function formatProgressEvent(event, serverBaseUrl) {
11014
10893
  }
11015
10894
  case 'phase_complete': {
11016
10895
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
11017
- const tokens = event.tokens ?? 0;
10896
+ const tokenStr = formatTokens(event.tokens);
11018
10897
  const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
11019
- return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
10898
+ return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
11020
10899
  }
11021
10900
  case 'synthesis_complete': {
11022
10901
  const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
11023
- const tokens = event.tokens ?? 0;
10902
+ const tokenStr = formatTokens(event.tokens);
11024
10903
  const duration = event.durationMs !== undefined
11025
10904
  ? formatSeconds(event.durationMs)
11026
10905
  : '0.0s';
11027
- return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
10906
+ return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
11028
10907
  }
11029
10908
  case 'error': {
11030
10909
  const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
@@ -11045,19 +10924,23 @@ class ProgressReporter {
11045
10924
  this.logger = logger;
11046
10925
  }
11047
10926
  async report(event) {
11048
- const target = this.config.reportChannel;
10927
+ // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
10928
+ // Legacy mode: reportChannel alone acts as the target (backward compatible).
10929
+ const target = this.config.reportTarget ?? this.config.reportChannel;
11049
10930
  if (!target)
11050
10931
  return;
11051
10932
  const message = formatProgressEvent(event, this.config.serverBaseUrl);
11052
10933
  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
- },
10934
+ const args = {
10935
+ action: 'send',
10936
+ target,
10937
+ message,
11060
10938
  };
10939
+ // Include channel field only in multi-channel mode (reportTarget is set)
10940
+ if (this.config.reportTarget && this.config.reportChannel) {
10941
+ args.channel = this.config.reportChannel;
10942
+ }
10943
+ const payload = { tool: 'message', args };
11061
10944
  try {
11062
10945
  const res = await fetch(url, {
11063
10946
  method: 'POST',
@@ -11662,14 +11545,19 @@ function metaExists(ownerPath) {
11662
11545
  * Walk returns file paths; we need the unique set of immediate parent
11663
11546
  * directories that could be owners.
11664
11547
  */
11665
- function extractDirectories(filePaths) {
11548
+ function extractDirectories(filePaths, logger) {
11666
11549
  const dirs = new Set();
11667
11550
  for (const fp of filePaths) {
11668
- const dir = posix.dirname(fp);
11551
+ // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11552
+ const normalized = normalizePath(fp);
11553
+ const dir = posix.dirname(normalized);
11669
11554
  if (dir !== '.' && dir !== '/') {
11670
11555
  dirs.add(dir);
11671
11556
  }
11672
11557
  }
11558
+ if (filePaths.length > 0 && dirs.size === 0) {
11559
+ logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
11560
+ }
11673
11561
  return [...dirs];
11674
11562
  }
11675
11563
  /**
@@ -11687,7 +11575,7 @@ async function autoSeedPass(rules, watcher, logger) {
11687
11575
  const candidates = new Map();
11688
11576
  for (const rule of rules) {
11689
11577
  const files = await watcher.walk([rule.match]);
11690
- const dirs = extractDirectories(files);
11578
+ const dirs = extractDirectories(files, logger);
11691
11579
  for (const dir of dirs) {
11692
11580
  candidates.set(dir, {
11693
11581
  steer: rule.steer,
@@ -11905,16 +11793,97 @@ function registerConfigRoute(app, deps) {
11905
11793
  }
11906
11794
 
11907
11795
  /**
11908
- * POST /config/apply — apply a config patch via the core SDK handler.
11796
+ * POST /config/apply — apply a config patch using the runtime config path.
11797
+ *
11798
+ * The core SDK's `createConfigApplyHandler` derives the config path from
11799
+ * `getComponentConfigDir()` which uses the npm global config root. This
11800
+ * local implementation uses the actual runtime config path instead, so
11801
+ * temp files are written alongside the active config file.
11909
11802
  *
11910
11803
  * @module routes/configApply
11911
11804
  */
11912
11805
  /** Register the POST /config/apply route. */
11913
- function registerConfigApplyRoute(app) {
11914
- const handler = createConfigApplyHandler(metaDescriptor);
11806
+ function registerConfigApplyRoute(app, configPath) {
11915
11807
  app.post('/config/apply', async (request, reply) => {
11916
- const result = await handler(request.body);
11917
- return reply.status(result.status).send(result.body);
11808
+ if (!configPath) {
11809
+ return reply
11810
+ .status(500)
11811
+ .send({ error: 'No runtime config path available' });
11812
+ }
11813
+ // Validate request body
11814
+ const body = request.body;
11815
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
11816
+ return reply
11817
+ .status(400)
11818
+ .send({ error: 'Request body must be a JSON object' });
11819
+ }
11820
+ const { patch, replace } = body;
11821
+ if (patch === null ||
11822
+ patch === undefined ||
11823
+ typeof patch !== 'object' ||
11824
+ Array.isArray(patch)) {
11825
+ return reply
11826
+ .status(400)
11827
+ .send({ error: '`patch` must be a non-null object' });
11828
+ }
11829
+ if (replace !== undefined && typeof replace !== 'boolean') {
11830
+ return reply
11831
+ .status(400)
11832
+ .send({ error: '`replace` must be a boolean if provided' });
11833
+ }
11834
+ // Read existing config from the runtime config path
11835
+ let existing = {};
11836
+ try {
11837
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
11838
+ }
11839
+ catch (err) {
11840
+ if (err instanceof SyntaxError ||
11841
+ (err instanceof Error && err.message.includes('JSON'))) {
11842
+ return reply.status(400).send({
11843
+ error: `Existing config file contains invalid JSON: ${err.message}`,
11844
+ });
11845
+ }
11846
+ // File missing — start from empty
11847
+ }
11848
+ // Merge or replace
11849
+ const merged = replace
11850
+ ? { ...patch }
11851
+ : { ...existing, ...patch };
11852
+ // Validate against schema
11853
+ const parseResult = serviceConfigSchema.safeParse(merged);
11854
+ if (!parseResult.success) {
11855
+ return reply.status(400).send({
11856
+ error: 'Config validation failed',
11857
+ issues: parseResult.error.issues,
11858
+ });
11859
+ }
11860
+ const validatedConfig = parseResult.data;
11861
+ // Write atomically — temp file lands next to the runtime config file
11862
+ try {
11863
+ const json = JSON.stringify(validatedConfig, null, 2) + '\n';
11864
+ atomicWrite(configPath, json);
11865
+ }
11866
+ catch (err) {
11867
+ const message = err instanceof Error ? err.message : String(err);
11868
+ return reply
11869
+ .status(500)
11870
+ .send({ error: `Failed to write config: ${message}` });
11871
+ }
11872
+ // Apply hot-reload callback
11873
+ try {
11874
+ applyHotReloadedConfig(validatedConfig);
11875
+ }
11876
+ catch (err) {
11877
+ const message = err instanceof Error ? err.message : String(err);
11878
+ return reply.status(200).send({
11879
+ applied: true,
11880
+ warning: `Config written but callback failed: ${message}`,
11881
+ restartRequired: RESTART_REQUIRED_FIELDS,
11882
+ });
11883
+ }
11884
+ return reply.status(200).send({
11885
+ applied: true,
11886
+ });
11918
11887
  });
11919
11888
  }
11920
11889
 
@@ -12348,6 +12317,16 @@ async function checkWatcher(url) {
12348
12317
  return { url, status: 'unreachable', checkedAt };
12349
12318
  }
12350
12319
  }
12320
+ /** Derive service-specific state from current activity and lifecycle. */
12321
+ function deriveServiceState(deps) {
12322
+ if (deps.shuttingDown)
12323
+ return 'stopping';
12324
+ if (deps.queue.current)
12325
+ return 'synthesizing';
12326
+ if (deps.queue.depth > 0)
12327
+ return 'waiting';
12328
+ return 'idle';
12329
+ }
12351
12330
  function registerStatusRoute(app, deps) {
12352
12331
  const statusHandler = createStatusHandler({
12353
12332
  name: SERVICE_NAME,
@@ -12360,6 +12339,7 @@ function registerStatusRoute(app, deps) {
12360
12339
  checkDependency(config.gatewayUrl, '/status'),
12361
12340
  ]);
12362
12341
  return {
12342
+ serviceState: deriveServiceState(deps),
12363
12343
  currentTarget: queue.current?.path ?? null,
12364
12344
  queue: queue.getState(),
12365
12345
  stats: {
@@ -12503,7 +12483,7 @@ function registerRoutes(app, deps) {
12503
12483
  registerSeedRoute(app, deps);
12504
12484
  registerUnlockRoute(app, deps);
12505
12485
  registerConfigRoute(app, deps);
12506
- registerConfigApplyRoute(app);
12486
+ registerConfigApplyRoute(app, deps.configPath);
12507
12487
  registerQueueRoutes(app, deps);
12508
12488
  }
12509
12489
 
@@ -12707,6 +12687,7 @@ async function startService(config, configPath) {
12707
12687
  scheduler,
12708
12688
  stats,
12709
12689
  executor,
12690
+ configPath,
12710
12691
  };
12711
12692
  registerConfigHotReloadRuntime({
12712
12693
  config,
@@ -12742,9 +12723,17 @@ async function startService(config, configPath) {
12742
12723
  try {
12743
12724
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
12744
12725
  // Track token stats from phase completions
12745
- if (evt.type === 'phase_complete' && evt.tokens) {
12746
- stats.totalTokens += evt.tokens;
12747
- cycleTokens += evt.tokens;
12726
+ if (evt.type === 'phase_complete') {
12727
+ if (evt.tokens !== undefined) {
12728
+ stats.totalTokens += evt.tokens;
12729
+ if (cycleTokens !== undefined) {
12730
+ cycleTokens += evt.tokens;
12731
+ }
12732
+ }
12733
+ else {
12734
+ cycleTokens = undefined;
12735
+ logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12736
+ }
12748
12737
  }
12749
12738
  await progress.report(evt);
12750
12739
  }, 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. */
@@ -9124,17 +8994,20 @@ class GatewayExecutor {
9124
8994
  }
9125
8995
  }
9126
8996
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
9127
- async invoke(tool, args) {
8997
+ async invoke(tool, args, sessionKey) {
9128
8998
  const headers = {
9129
8999
  'Content-Type': 'application/json',
9130
9000
  };
9131
9001
  if (this.apiKey) {
9132
9002
  headers['Authorization'] = 'Bearer ' + this.apiKey;
9133
9003
  }
9004
+ const body = { tool, args };
9005
+ if (sessionKey)
9006
+ body.sessionKey = sessionKey;
9134
9007
  const res = await fetch(this.gatewayUrl + '/tools/invoke', {
9135
9008
  method: 'POST',
9136
9009
  headers,
9137
- body: JSON.stringify({ tool, args }),
9010
+ body: JSON.stringify(body),
9138
9011
  });
9139
9012
  if (!res.ok) {
9140
9013
  const text = await res.text();
@@ -9147,12 +9020,12 @@ class GatewayExecutor {
9147
9020
  return data;
9148
9021
  }
9149
9022
  /** Look up totalTokens for a session via sessions_list. */
9150
- async getSessionTokens(sessionKey) {
9023
+ async getSessionTokens(sessionKey, invokeSessionKey) {
9151
9024
  try {
9152
9025
  const result = await this.invoke('sessions_list', {
9153
9026
  limit: 20,
9154
9027
  messageLimit: 0,
9155
- });
9028
+ }, invokeSessionKey);
9156
9029
  const sessions = (result.result?.details?.sessions ??
9157
9030
  result.result?.sessions ??
9158
9031
  []);
@@ -9180,6 +9053,7 @@ class GatewayExecutor {
9180
9053
  // Generate unique output path for file-based output
9181
9054
  const outputId = randomUUID();
9182
9055
  const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
9056
+ const invokeSessionKey = 'agent:main:meta-invoke:' + outputId;
9183
9057
  // Append file output instruction to the task
9184
9058
  const taskWithOutput = task +
9185
9059
  '\n\n## OUTPUT DELIVERY\n\n' +
@@ -9197,7 +9071,7 @@ class GatewayExecutor {
9197
9071
  runTimeoutSeconds: timeoutSeconds,
9198
9072
  ...(options?.thinking ? { thinking: options.thinking } : {}),
9199
9073
  ...(options?.model ? { model: options.model } : {}),
9200
- });
9074
+ }, invokeSessionKey);
9201
9075
  const details = (spawnResult.result?.details ?? spawnResult.result);
9202
9076
  const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9203
9077
  if (typeof sessionKey !== 'string' || !sessionKey) {
@@ -9217,7 +9091,7 @@ class GatewayExecutor {
9217
9091
  sessionKey,
9218
9092
  limit: 5,
9219
9093
  includeTools: false,
9220
- });
9094
+ }, invokeSessionKey);
9221
9095
  const messages = historyResult.result?.details?.messages ??
9222
9096
  historyResult.result?.messages ??
9223
9097
  [];
@@ -9230,7 +9104,7 @@ class GatewayExecutor {
9230
9104
  lastMsg.stopReason !== 'toolUse' &&
9231
9105
  lastMsg.stopReason !== 'error') {
9232
9106
  // Fetch token usage from session metadata
9233
- const tokens = await this.getSessionTokens(sessionKey);
9107
+ const tokens = await this.getSessionTokens(sessionKey, invokeSessionKey);
9234
9108
  // Read output from file (sub-agent wrote it via Write tool)
9235
9109
  if (existsSync(outputPath)) {
9236
9110
  try {
@@ -10495,6 +10369,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
10495
10369
  function formatNumber(n) {
10496
10370
  return n.toLocaleString('en-US');
10497
10371
  }
10372
+ function formatTokens(tokens) {
10373
+ return tokens !== undefined
10374
+ ? formatNumber(tokens) + ' tokens'
10375
+ : 'unknown tokens';
10376
+ }
10498
10377
  function formatSeconds(durationMs) {
10499
10378
  const seconds = durationMs / 1000;
10500
10379
  return Math.round(seconds).toString() + 's';
@@ -10545,17 +10424,17 @@ function formatProgressEvent(event, serverBaseUrl) {
10545
10424
  }
10546
10425
  case 'phase_complete': {
10547
10426
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
10548
- const tokens = event.tokens ?? 0;
10427
+ const tokenStr = formatTokens(event.tokens);
10549
10428
  const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
10550
- return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
10429
+ return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
10551
10430
  }
10552
10431
  case 'synthesis_complete': {
10553
10432
  const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
10554
- const tokens = event.tokens ?? 0;
10433
+ const tokenStr = formatTokens(event.tokens);
10555
10434
  const duration = event.durationMs !== undefined
10556
10435
  ? formatSeconds(event.durationMs)
10557
10436
  : '0.0s';
10558
- return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
10437
+ return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
10559
10438
  }
10560
10439
  case 'error': {
10561
10440
  const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
@@ -10576,19 +10455,23 @@ class ProgressReporter {
10576
10455
  this.logger = logger;
10577
10456
  }
10578
10457
  async report(event) {
10579
- const target = this.config.reportChannel;
10458
+ // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
10459
+ // Legacy mode: reportChannel alone acts as the target (backward compatible).
10460
+ const target = this.config.reportTarget ?? this.config.reportChannel;
10580
10461
  if (!target)
10581
10462
  return;
10582
10463
  const message = formatProgressEvent(event, this.config.serverBaseUrl);
10583
10464
  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
- },
10465
+ const args = {
10466
+ action: 'send',
10467
+ target,
10468
+ message,
10591
10469
  };
10470
+ // Include channel field only in multi-channel mode (reportTarget is set)
10471
+ if (this.config.reportTarget && this.config.reportChannel) {
10472
+ args.channel = this.config.reportChannel;
10473
+ }
10474
+ const payload = { tool: 'message', args };
10592
10475
  try {
10593
10476
  const res = await fetch(url, {
10594
10477
  method: 'POST',
@@ -11193,14 +11076,19 @@ function metaExists(ownerPath) {
11193
11076
  * Walk returns file paths; we need the unique set of immediate parent
11194
11077
  * directories that could be owners.
11195
11078
  */
11196
- function extractDirectories(filePaths) {
11079
+ function extractDirectories(filePaths, logger) {
11197
11080
  const dirs = new Set();
11198
11081
  for (const fp of filePaths) {
11199
- const dir = posix.dirname(fp);
11082
+ // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11083
+ const normalized = normalizePath(fp);
11084
+ const dir = posix.dirname(normalized);
11200
11085
  if (dir !== '.' && dir !== '/') {
11201
11086
  dirs.add(dir);
11202
11087
  }
11203
11088
  }
11089
+ if (filePaths.length > 0 && dirs.size === 0) {
11090
+ logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
11091
+ }
11204
11092
  return [...dirs];
11205
11093
  }
11206
11094
  /**
@@ -11218,7 +11106,7 @@ async function autoSeedPass(rules, watcher, logger) {
11218
11106
  const candidates = new Map();
11219
11107
  for (const rule of rules) {
11220
11108
  const files = await watcher.walk([rule.match]);
11221
- const dirs = extractDirectories(files);
11109
+ const dirs = extractDirectories(files, logger);
11222
11110
  for (const dir of dirs) {
11223
11111
  candidates.set(dir, {
11224
11112
  steer: rule.steer,
@@ -11436,16 +11324,97 @@ function registerConfigRoute(app, deps) {
11436
11324
  }
11437
11325
 
11438
11326
  /**
11439
- * POST /config/apply — apply a config patch via the core SDK handler.
11327
+ * POST /config/apply — apply a config patch using the runtime config path.
11328
+ *
11329
+ * The core SDK's `createConfigApplyHandler` derives the config path from
11330
+ * `getComponentConfigDir()` which uses the npm global config root. This
11331
+ * local implementation uses the actual runtime config path instead, so
11332
+ * temp files are written alongside the active config file.
11440
11333
  *
11441
11334
  * @module routes/configApply
11442
11335
  */
11443
11336
  /** Register the POST /config/apply route. */
11444
- function registerConfigApplyRoute(app) {
11445
- const handler = createConfigApplyHandler(metaDescriptor);
11337
+ function registerConfigApplyRoute(app, configPath) {
11446
11338
  app.post('/config/apply', async (request, reply) => {
11447
- const result = await handler(request.body);
11448
- return reply.status(result.status).send(result.body);
11339
+ if (!configPath) {
11340
+ return reply
11341
+ .status(500)
11342
+ .send({ error: 'No runtime config path available' });
11343
+ }
11344
+ // Validate request body
11345
+ const body = request.body;
11346
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
11347
+ return reply
11348
+ .status(400)
11349
+ .send({ error: 'Request body must be a JSON object' });
11350
+ }
11351
+ const { patch, replace } = body;
11352
+ if (patch === null ||
11353
+ patch === undefined ||
11354
+ typeof patch !== 'object' ||
11355
+ Array.isArray(patch)) {
11356
+ return reply
11357
+ .status(400)
11358
+ .send({ error: '`patch` must be a non-null object' });
11359
+ }
11360
+ if (replace !== undefined && typeof replace !== 'boolean') {
11361
+ return reply
11362
+ .status(400)
11363
+ .send({ error: '`replace` must be a boolean if provided' });
11364
+ }
11365
+ // Read existing config from the runtime config path
11366
+ let existing = {};
11367
+ try {
11368
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
11369
+ }
11370
+ catch (err) {
11371
+ if (err instanceof SyntaxError ||
11372
+ (err instanceof Error && err.message.includes('JSON'))) {
11373
+ return reply.status(400).send({
11374
+ error: `Existing config file contains invalid JSON: ${err.message}`,
11375
+ });
11376
+ }
11377
+ // File missing — start from empty
11378
+ }
11379
+ // Merge or replace
11380
+ const merged = replace
11381
+ ? { ...patch }
11382
+ : { ...existing, ...patch };
11383
+ // Validate against schema
11384
+ const parseResult = serviceConfigSchema.safeParse(merged);
11385
+ if (!parseResult.success) {
11386
+ return reply.status(400).send({
11387
+ error: 'Config validation failed',
11388
+ issues: parseResult.error.issues,
11389
+ });
11390
+ }
11391
+ const validatedConfig = parseResult.data;
11392
+ // Write atomically — temp file lands next to the runtime config file
11393
+ try {
11394
+ const json = JSON.stringify(validatedConfig, null, 2) + '\n';
11395
+ atomicWrite(configPath, json);
11396
+ }
11397
+ catch (err) {
11398
+ const message = err instanceof Error ? err.message : String(err);
11399
+ return reply
11400
+ .status(500)
11401
+ .send({ error: `Failed to write config: ${message}` });
11402
+ }
11403
+ // Apply hot-reload callback
11404
+ try {
11405
+ applyHotReloadedConfig(validatedConfig);
11406
+ }
11407
+ catch (err) {
11408
+ const message = err instanceof Error ? err.message : String(err);
11409
+ return reply.status(200).send({
11410
+ applied: true,
11411
+ warning: `Config written but callback failed: ${message}`,
11412
+ restartRequired: RESTART_REQUIRED_FIELDS,
11413
+ });
11414
+ }
11415
+ return reply.status(200).send({
11416
+ applied: true,
11417
+ });
11449
11418
  });
11450
11419
  }
11451
11420
 
@@ -11851,6 +11820,16 @@ async function checkWatcher(url) {
11851
11820
  return { url, status: 'unreachable', checkedAt };
11852
11821
  }
11853
11822
  }
11823
+ /** Derive service-specific state from current activity and lifecycle. */
11824
+ function deriveServiceState(deps) {
11825
+ if (deps.shuttingDown)
11826
+ return 'stopping';
11827
+ if (deps.queue.current)
11828
+ return 'synthesizing';
11829
+ if (deps.queue.depth > 0)
11830
+ return 'waiting';
11831
+ return 'idle';
11832
+ }
11854
11833
  function registerStatusRoute(app, deps) {
11855
11834
  const statusHandler = createStatusHandler({
11856
11835
  name: SERVICE_NAME,
@@ -11863,6 +11842,7 @@ function registerStatusRoute(app, deps) {
11863
11842
  checkDependency(config.gatewayUrl, '/status'),
11864
11843
  ]);
11865
11844
  return {
11845
+ serviceState: deriveServiceState(deps),
11866
11846
  currentTarget: queue.current?.path ?? null,
11867
11847
  queue: queue.getState(),
11868
11848
  stats: {
@@ -12006,7 +11986,7 @@ function registerRoutes(app, deps) {
12006
11986
  registerSeedRoute(app, deps);
12007
11987
  registerUnlockRoute(app, deps);
12008
11988
  registerConfigRoute(app, deps);
12009
- registerConfigApplyRoute(app);
11989
+ registerConfigApplyRoute(app, deps.configPath);
12010
11990
  registerQueueRoutes(app, deps);
12011
11991
  }
12012
11992
 
@@ -12210,6 +12190,7 @@ async function startService(config, configPath) {
12210
12190
  scheduler,
12211
12191
  stats,
12212
12192
  executor,
12193
+ configPath,
12213
12194
  };
12214
12195
  registerConfigHotReloadRuntime({
12215
12196
  config,
@@ -12245,9 +12226,17 @@ async function startService(config, configPath) {
12245
12226
  try {
12246
12227
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
12247
12228
  // Track token stats from phase completions
12248
- if (evt.type === 'phase_complete' && evt.tokens) {
12249
- stats.totalTokens += evt.tokens;
12250
- cycleTokens += evt.tokens;
12229
+ if (evt.type === 'phase_complete') {
12230
+ if (evt.tokens !== undefined) {
12231
+ stats.totalTokens += evt.tokens;
12232
+ if (cycleTokens !== undefined) {
12233
+ cycleTokens += evt.tokens;
12234
+ }
12235
+ }
12236
+ else {
12237
+ cycleTokens = undefined;
12238
+ logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12239
+ }
12251
12240
  }
12252
12241
  await progress.report(evt);
12253
12242
  }, 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.8",
4
4
  "author": "Jason Williscroft",
5
5
  "description": "Fastify HTTP service for the Jeeves Meta synthesis engine",
6
6
  "license": "BSD-3-Clause",