@karmaniverous/jeeves-watcher-openclaw 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs, { readFileSync, existsSync, unlinkSync, mkdirSync, writeFileSync, renameSync, cpSync } from 'node:fs';
2
2
  import path, { join, dirname, basename } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import require$$0$4 from 'path';
4
5
  import require$$0$3 from 'fs';
5
6
  import require$$0$1 from 'constants';
@@ -11,10 +12,9 @@ import 'vm';
11
12
  import require$$0$5 from 'node:events';
12
13
  import require$$1, { execSync } from 'node:child_process';
13
14
  import process$2 from 'node:process';
14
- import 'node:fs/promises';
15
- import { fileURLToPath } from 'node:url';
16
15
  import { homedir } from 'node:os';
17
- import 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import 'node:fs/promises';
18
18
 
19
19
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
20
20
 
@@ -15480,14 +15480,14 @@ const PLATFORM_COMPONENTS = [
15480
15480
  * Core library version, inlined at build time.
15481
15481
  *
15482
15482
  * @remarks
15483
- * The `0.5.0` placeholder is replaced by
15483
+ * The `0.5.3` placeholder is replaced by
15484
15484
  * `@rollup/plugin-replace` during the build with the actual version
15485
15485
  * from `package.json`. This ensures the correct version survives
15486
15486
  * when consumers bundle core into their own dist (where runtime
15487
15487
  * `import.meta.url`-based resolution would find the wrong package.json).
15488
15488
  */
15489
15489
  /** The core library version from package.json (inlined at build time). */
15490
- const CORE_VERSION = '0.5.0';
15490
+ const CORE_VERSION = '0.5.3';
15491
15491
 
15492
15492
  /**
15493
15493
  * Workspace and config root initialization.
@@ -15569,27 +15569,46 @@ const STALE_LOCK_MS = 120_000;
15569
15569
  const DEFAULT_CORE_VERSION = CORE_VERSION;
15570
15570
  /** Lock retry options. */
15571
15571
  const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
15572
+ /** Maximum rename retry attempts on EPERM. */
15573
+ const ATOMIC_WRITE_MAX_RETRIES = 3;
15574
+ /** Delay between EPERM retries in milliseconds. */
15575
+ const ATOMIC_WRITE_RETRY_DELAY_MS = 100;
15572
15576
  /**
15573
15577
  * Write content to a file atomically via a temp file + rename.
15574
15578
  *
15579
+ * @remarks
15580
+ * Retries the rename up to three times on EPERM (Windows file-handle
15581
+ * contention) with a 100 ms synchronous delay between attempts.
15582
+ *
15575
15583
  * @param filePath - Absolute path to the target file.
15576
15584
  * @param content - Content to write.
15577
15585
  */
15578
15586
  function atomicWrite(filePath, content) {
15579
15587
  const dir = dirname(filePath);
15580
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
15588
+ const base = basename(filePath, '.md');
15589
+ const tempPath = join(dir, `.${base}.${String(Date.now())}.${randomUUID().slice(0, 8)}.tmp`);
15581
15590
  writeFileSync(tempPath, content, 'utf-8');
15582
- try {
15583
- renameSync(tempPath, filePath);
15584
- }
15585
- catch (err) {
15591
+ for (let attempt = 0; attempt < ATOMIC_WRITE_MAX_RETRIES; attempt++) {
15586
15592
  try {
15587
- unlinkSync(tempPath);
15593
+ renameSync(tempPath, filePath);
15594
+ return;
15588
15595
  }
15589
- catch {
15590
- /* best-effort cleanup */
15596
+ catch (err) {
15597
+ const isEperm = err instanceof Error &&
15598
+ 'code' in err &&
15599
+ err.code === 'EPERM';
15600
+ if (!isEperm || attempt === ATOMIC_WRITE_MAX_RETRIES - 1) {
15601
+ try {
15602
+ unlinkSync(tempPath);
15603
+ }
15604
+ catch {
15605
+ /* best-effort cleanup */
15606
+ }
15607
+ throw err;
15608
+ }
15609
+ // Synchronous sleep before retry (acceptable in atomic write context)
15610
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ATOMIC_WRITE_RETRY_DELAY_MS);
15591
15611
  }
15592
- throw err;
15593
15612
  }
15594
15613
  }
15595
15614
  /**
@@ -15624,6 +15643,21 @@ async function withFileLock(filePath, fn) {
15624
15643
  }
15625
15644
  }
15626
15645
 
15646
+ /**
15647
+ * Shared internal utility functions.
15648
+ *
15649
+ * @packageDocumentation
15650
+ */
15651
+ /**
15652
+ * Extract a human-readable message from an unknown caught value.
15653
+ *
15654
+ * @param err - The caught value (typically `unknown`).
15655
+ * @returns The error message string.
15656
+ */
15657
+ function getErrorMessage(err) {
15658
+ return err instanceof Error ? err.message : String(err);
15659
+ }
15660
+
15627
15661
  /**
15628
15662
  * Workspace-level shared configuration: `jeeves.config.json`.
15629
15663
  *
@@ -15673,7 +15707,13 @@ const workspaceConfigSchema = object({
15673
15707
  /** Memory hygiene shared defaults. */
15674
15708
  memory: workspaceMemoryConfigSchema.optional(),
15675
15709
  });
15676
- /** Built-in workspace config defaults. */
15710
+ /**
15711
+ * Built-in workspace config defaults.
15712
+ *
15713
+ * @remarks
15714
+ * These defaults are used as the lowest-priority tier in config resolution
15715
+ * (below CLI flags, env vars, and `jeeves.config.json` values).
15716
+ */
15677
15717
  const WORKSPACE_CONFIG_DEFAULTS = {
15678
15718
  core: {
15679
15719
  workspace: '.',
@@ -15702,8 +15742,7 @@ function loadWorkspaceConfig(workspacePath) {
15702
15742
  return workspaceConfigSchema.parse(parsed);
15703
15743
  }
15704
15744
  catch (err) {
15705
- const msg = err instanceof Error ? err.message : String(err);
15706
- console.warn(`jeeves-core: failed to load ${configPath}: ${msg}`);
15745
+ console.warn(`jeeves-core: failed to load ${configPath}: ${getErrorMessage(err)}`);
15707
15746
  return undefined;
15708
15747
  }
15709
15748
  }
@@ -15882,7 +15921,7 @@ function parseHeartbeat(fileContent) {
15882
15921
  const userContent = fileContent.slice(0, headingIndex).trim();
15883
15922
  const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
15884
15923
  const entries = [];
15885
- const h2Re = /^## (jeeves-\S+?|MEMORY\.md)(?:: declined)?$/gm;
15924
+ const h2Re = /^## (jeeves-\S+?|\S+\.md)(?:: declined)?$/gm;
15886
15925
  let match;
15887
15926
  const h2Positions = [];
15888
15927
  while ((match = h2Re.exec(sectionContent)) !== null) {
@@ -15963,8 +16002,7 @@ async function writeHeartbeatSection(filePath, entries) {
15963
16002
  });
15964
16003
  }
15965
16004
  catch (err) {
15966
- const message = err instanceof Error ? err.message : String(err);
15967
- console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${message}`);
16005
+ console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${getErrorMessage(err)}`);
15968
16006
  }
15969
16007
  }
15970
16008
 
@@ -16001,6 +16039,15 @@ function sortSectionsByOrder(sections) {
16001
16039
  * sections within the block, and returns the structured result plus
16002
16040
  * user content outside the markers.
16003
16041
  */
16042
+ /**
16043
+ * Escape a string for safe use as a literal in a RegExp pattern.
16044
+ *
16045
+ * @param str - The string to escape.
16046
+ * @returns The escaped string.
16047
+ */
16048
+ function escapeForRegex(str) {
16049
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16050
+ }
16004
16051
  /**
16005
16052
  * Build regex patterns for the given markers.
16006
16053
  *
@@ -16008,11 +16055,9 @@ function sortSectionsByOrder(sections) {
16008
16055
  * @returns Object with begin and end regex patterns.
16009
16056
  */
16010
16057
  function buildMarkerPatterns(markers) {
16011
- const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16012
- const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16013
16058
  return {
16014
- beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
16015
- endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
16059
+ beginRe: new RegExp(`^<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
16060
+ endRe: new RegExp(`^<!--\\s*${escapeForRegex(markers.end)}\\s*-->\\s*$`, 'm'),
16016
16061
  };
16017
16062
  }
16018
16063
  /**
@@ -16289,63 +16334,6 @@ function getEffectiveServiceName(descriptor) {
16289
16334
  return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
16290
16335
  }
16291
16336
 
16292
- /**
16293
- * HTTP helpers for the OpenClaw plugin SDK.
16294
- *
16295
- * @remarks
16296
- * Thin wrappers around `fetch` that throw on non-OK responses
16297
- * and handle JSON serialisation/deserialisation.
16298
- */
16299
- /**
16300
- * Fetch a URL with an automatic abort timeout.
16301
- *
16302
- * @param url - URL to fetch.
16303
- * @param timeoutMs - Timeout in milliseconds before aborting.
16304
- * @param init - Optional `fetch` init options.
16305
- * @returns The fetch Response object.
16306
- */
16307
- async function fetchWithTimeout(url, timeoutMs, init) {
16308
- const controller = new AbortController();
16309
- const timeout = setTimeout(() => {
16310
- controller.abort();
16311
- }, timeoutMs);
16312
- try {
16313
- return await fetch(url, { ...init, signal: controller.signal });
16314
- }
16315
- finally {
16316
- clearTimeout(timeout);
16317
- }
16318
- }
16319
- /**
16320
- * Fetch JSON from a URL, throwing on non-OK responses.
16321
- *
16322
- * @param url - URL to fetch.
16323
- * @param init - Optional `fetch` init options.
16324
- * @returns Parsed JSON response body.
16325
- * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
16326
- */
16327
- async function fetchJson(url, init) {
16328
- const res = await fetch(url, init);
16329
- if (!res.ok) {
16330
- throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
16331
- }
16332
- return res.json();
16333
- }
16334
- /**
16335
- * POST JSON to a URL and return parsed response.
16336
- *
16337
- * @param url - URL to POST to.
16338
- * @param body - Request body (will be JSON-stringified).
16339
- * @returns Parsed JSON response body.
16340
- */
16341
- async function postJson(url, body) {
16342
- return fetchJson(url, {
16343
- method: 'POST',
16344
- headers: { 'Content-Type': 'application/json' },
16345
- body: JSON.stringify(body),
16346
- });
16347
- }
16348
-
16349
16337
  /**
16350
16338
  * Platform-aware service state detection.
16351
16339
  *
@@ -16722,114 +16710,494 @@ function createServiceManager(descriptor) {
16722
16710
  }
16723
16711
 
16724
16712
  /**
16725
- * Similarity-based cleanup detection for orphaned managed content.
16713
+ * HTTP helpers for the OpenClaw plugin SDK.
16726
16714
  *
16727
16715
  * @remarks
16728
- * Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
16729
- * when orphaned managed content exists in the user content zone.
16716
+ * Thin wrappers around `fetch` that throw on non-OK responses
16717
+ * and handle JSON serialisation/deserialisation.
16730
16718
  */
16731
- /** Default similarity threshold for cleanup detection. */
16732
- const DEFAULT_THRESHOLD = 0.15;
16733
16719
  /**
16734
- * Generate a set of n-word shingles from text.
16720
+ * Fetch a URL with an automatic abort timeout.
16735
16721
  *
16736
- * @param text - Input text.
16737
- * @param n - Shingle size (default 3).
16738
- * @returns Set of n-word shingles.
16722
+ * @param url - URL to fetch.
16723
+ * @param timeoutMs - Timeout in milliseconds before aborting.
16724
+ * @param init - Optional `fetch` init options.
16725
+ * @returns The fetch Response object.
16739
16726
  */
16740
- function shingles(text, n = 3) {
16741
- const words = text.toLowerCase().split(/\s+/).filter(Boolean);
16742
- const set = new Set();
16743
- for (let i = 0; i <= words.length - n; i++) {
16744
- set.add(words.slice(i, i + n).join(' '));
16727
+ async function fetchWithTimeout(url, timeoutMs, init) {
16728
+ const controller = new AbortController();
16729
+ const timeout = setTimeout(() => {
16730
+ controller.abort();
16731
+ }, timeoutMs);
16732
+ try {
16733
+ return await fetch(url, { ...init, signal: controller.signal });
16734
+ }
16735
+ finally {
16736
+ clearTimeout(timeout);
16745
16737
  }
16746
- return set;
16747
16738
  }
16748
16739
  /**
16749
- * Compute Jaccard similarity between two sets.
16740
+ * Fetch JSON from a URL, throwing on non-OK responses.
16750
16741
  *
16751
- * @param a - First set.
16752
- * @param b - Second set.
16753
- * @returns Jaccard similarity coefficient (0 to 1).
16742
+ * @param url - URL to fetch.
16743
+ * @param init - Optional `fetch` init options.
16744
+ * @returns Parsed JSON response body.
16745
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
16754
16746
  */
16755
- function jaccard(a, b) {
16756
- if (a.size === 0 && b.size === 0)
16757
- return 0;
16758
- let intersection = 0;
16759
- for (const item of a) {
16760
- if (b.has(item))
16761
- intersection++;
16747
+ async function fetchJson(url, init) {
16748
+ const res = await fetch(url, init);
16749
+ if (!res.ok) {
16750
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
16762
16751
  }
16763
- return intersection / (a.size + b.size - intersection);
16752
+ return res.json();
16764
16753
  }
16765
16754
  /**
16766
- * Check whether user content contains orphaned managed content.
16755
+ * POST JSON to a URL and return parsed response.
16767
16756
  *
16768
- * @param managedContent - The current managed block content.
16769
- * @param userContent - Content below the END marker.
16770
- * @param threshold - Jaccard threshold (default 0.15).
16771
- * @returns `true` if cleanup is needed.
16757
+ * @param url - URL to POST to.
16758
+ * @param body - Request body (will be JSON-stringified).
16759
+ * @returns Parsed JSON response body.
16772
16760
  */
16773
- function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD) {
16774
- if (!userContent.trim())
16775
- return false;
16776
- return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
16761
+ async function postJson(url, body) {
16762
+ return fetchJson(url, {
16763
+ method: 'POST',
16764
+ headers: { 'Content-Type': 'application/json' },
16765
+ body: JSON.stringify(body),
16766
+ });
16777
16767
  }
16778
16768
 
16779
16769
  /**
16780
- * Strip foreign managed blocks from content.
16770
+ * Tool result formatters for the OpenClaw plugin SDK.
16781
16771
  *
16782
16772
  * @remarks
16783
- * Prevents cross-contamination by removing managed blocks that belong
16784
- * to other marker sets. For example, when writing TOOLS.md with TOOLS
16785
- * markers, any SOUL or AGENTS managed blocks found in the user content
16786
- * zone are stripped — they don't belong there.
16773
+ * Provides standardised helpers for building `ToolResult` objects:
16774
+ * success, error, and connection-error variants.
16775
+ */
16776
+ /**
16777
+ * Format a successful tool result.
16787
16778
  *
16788
- * @packageDocumentation
16779
+ * @param data - Arbitrary data to return as JSON.
16780
+ * @returns A `ToolResult` with JSON-stringified content.
16789
16781
  */
16782
+ function ok(data) {
16783
+ return {
16784
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
16785
+ };
16786
+ }
16790
16787
  /**
16791
- * Build a regex that matches an entire managed block (BEGIN marker through END marker).
16788
+ * Format an error tool result.
16792
16789
  *
16793
- * @param markers - The marker set to match.
16794
- * @returns A regex that matches the full block including markers.
16790
+ * @param error - Error instance, string, or other value.
16791
+ * @returns A `ToolResult` with `isError: true`.
16795
16792
  */
16796
- function buildBlockPattern(markers) {
16797
- const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16798
- const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16799
- return new RegExp(`\\s*<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->[\\s\\S]*?<!--\\s*${escapedEnd}\\s*-->\\s*`, 'g');
16793
+ function fail(error) {
16794
+ const message = error instanceof Error ? error.message : String(error);
16795
+ return {
16796
+ content: [{ type: 'text', text: 'Error: ' + message }],
16797
+ isError: true,
16798
+ };
16800
16799
  }
16801
16800
  /**
16802
- * Strip managed blocks belonging to foreign marker sets from content.
16801
+ * Format a connection error with actionable guidance.
16803
16802
  *
16804
- * @param content - The content to clean (typically user content zone).
16805
- * @param currentMarkers - The marker set that owns this file (will NOT be stripped).
16806
- * @returns Content with foreign managed blocks removed.
16803
+ * @remarks
16804
+ * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
16805
+ * `error.cause.code` and returns a user-friendly message referencing
16806
+ * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
16807
+ * non-connection errors.
16808
+ *
16809
+ * @param error - Error instance (typically from `fetch`).
16810
+ * @param baseUrl - The URL that was being contacted.
16811
+ * @param pluginId - The plugin identifier for config guidance.
16812
+ * @returns A `ToolResult` with `isError: true`.
16807
16813
  */
16808
- function stripForeignMarkers(content, currentMarkers) {
16809
- let result = content;
16810
- for (const markers of ALL_MARKERS) {
16811
- // Skip the current file's own markers
16812
- if (markers.begin === currentMarkers.begin)
16813
- continue;
16814
- const pattern = buildBlockPattern(markers);
16815
- result = result.replace(pattern, '\n');
16814
+ function connectionFail(error, baseUrl, pluginId) {
16815
+ const cause = error instanceof Error ? error.cause : undefined;
16816
+ const code = cause && typeof cause === 'object' && 'code' in cause
16817
+ ? String(cause.code)
16818
+ : '';
16819
+ const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
16820
+ if (isConnectionError) {
16821
+ return {
16822
+ content: [
16823
+ {
16824
+ type: 'text',
16825
+ text: [
16826
+ `Service not reachable at ${baseUrl}.`,
16827
+ 'Either start the service, or if it runs on a different port,',
16828
+ `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
16829
+ ].join('\n'),
16830
+ },
16831
+ ],
16832
+ isError: true,
16833
+ };
16816
16834
  }
16817
- // Clean up multiple blank lines left by removals
16818
- return result.replace(/\n{3,}/g, '\n\n').trim();
16835
+ return fail(error);
16819
16836
  }
16820
16837
 
16821
16838
  /**
16822
- * Generic managed-section writer with block and section modes.
16839
+ * Factory for the standard plugin tool set.
16823
16840
  *
16824
16841
  * @remarks
16825
- * Supports two modes:
16826
- * - `block`: Replaces the entire managed block (SOUL.md, AGENTS.md).
16827
- * - `section`: Upserts a named H2 section within the managed block (TOOLS.md).
16842
+ * Produces four standard tools from a component descriptor:
16843
+ * - `{name}_status` - Probe service health + version + uptime
16844
+ * - `{name}_config` - Query running config with optional JSONPath
16845
+ * - `{name}_config_apply` - Push config patch to running service
16846
+ * - `{name}_service` - Service lifecycle management
16828
16847
  *
16829
- * Provides file-level locking, version-stamp convergence, and atomic writes.
16848
+ * Components add domain-specific tools separately.
16830
16849
  */
16850
+ /** Timeout for HTTP probes in milliseconds. */
16851
+ const PROBE_TIMEOUT_MS$1 = 5000;
16831
16852
  /**
16832
- * Update a managed section in a file.
16853
+ * Create the standard plugin tool set from a component descriptor.
16854
+ *
16855
+ * @param descriptor - The component descriptor.
16856
+ * @returns Array of tool descriptors to register.
16857
+ */
16858
+ function createPluginToolset(descriptor) {
16859
+ const { name, defaultPort } = descriptor;
16860
+ const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
16861
+ const svcManager = createServiceManager(descriptor);
16862
+ const statusTool = {
16863
+ name: `${name}_status`,
16864
+ description: `Get ${name} service health, version, and uptime.`,
16865
+ parameters: {
16866
+ type: 'object',
16867
+ properties: {},
16868
+ },
16869
+ execute: async () => {
16870
+ try {
16871
+ const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS$1);
16872
+ if (!res.ok) {
16873
+ return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
16874
+ }
16875
+ const data = await res.json();
16876
+ return ok(data);
16877
+ }
16878
+ catch (err) {
16879
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16880
+ }
16881
+ },
16882
+ };
16883
+ const configTool = {
16884
+ name: `${name}_config`,
16885
+ description: `Query ${name} running configuration. Optional JSONPath filter.`,
16886
+ parameters: {
16887
+ type: 'object',
16888
+ properties: {
16889
+ path: {
16890
+ type: 'string',
16891
+ description: 'JSONPath expression (optional)',
16892
+ },
16893
+ },
16894
+ },
16895
+ execute: async (_id, params) => {
16896
+ const path = params.path;
16897
+ const qs = path ? `?path=${encodeURIComponent(path)}` : '';
16898
+ try {
16899
+ const result = await fetchJson(`${baseUrl}/config${qs}`);
16900
+ return ok(result);
16901
+ }
16902
+ catch (err) {
16903
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16904
+ }
16905
+ },
16906
+ };
16907
+ const configApplyTool = {
16908
+ name: `${name}_config_apply`,
16909
+ description: `Apply a config patch to the running ${name} service.`,
16910
+ parameters: {
16911
+ type: 'object',
16912
+ properties: {
16913
+ config: {
16914
+ type: 'object',
16915
+ description: 'Config patch to apply',
16916
+ },
16917
+ },
16918
+ required: ['config'],
16919
+ },
16920
+ execute: async (_id, params) => {
16921
+ const config = params.config;
16922
+ if (!config) {
16923
+ return fail('Missing required parameter: config');
16924
+ }
16925
+ try {
16926
+ const result = await postJson(`${baseUrl}/config/apply`, {
16927
+ patch: config,
16928
+ });
16929
+ return ok(result);
16930
+ }
16931
+ catch (err) {
16932
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16933
+ }
16934
+ },
16935
+ };
16936
+ const serviceTool = {
16937
+ name: `${name}_service`,
16938
+ description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
16939
+ parameters: {
16940
+ type: 'object',
16941
+ properties: {
16942
+ action: {
16943
+ type: 'string',
16944
+ enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
16945
+ description: 'Service action to perform',
16946
+ },
16947
+ },
16948
+ required: ['action'],
16949
+ },
16950
+ execute: (_id, params) => {
16951
+ const action = params.action;
16952
+ const validActions = [
16953
+ 'install',
16954
+ 'uninstall',
16955
+ 'start',
16956
+ 'stop',
16957
+ 'restart',
16958
+ 'status',
16959
+ ];
16960
+ if (!validActions.includes(action)) {
16961
+ return Promise.resolve(fail(`Invalid action: ${action}`));
16962
+ }
16963
+ try {
16964
+ if (action === 'status') {
16965
+ const state = svcManager.status();
16966
+ return Promise.resolve(ok({ service: name, state }));
16967
+ }
16968
+ // Call the appropriate method
16969
+ const methodMap = {
16970
+ install: () => {
16971
+ svcManager.install();
16972
+ },
16973
+ uninstall: () => {
16974
+ svcManager.uninstall();
16975
+ },
16976
+ start: () => {
16977
+ svcManager.start();
16978
+ },
16979
+ stop: () => {
16980
+ svcManager.stop();
16981
+ },
16982
+ restart: () => {
16983
+ svcManager.restart();
16984
+ },
16985
+ };
16986
+ methodMap[action]();
16987
+ return Promise.resolve(ok({ service: name, action, success: true }));
16988
+ }
16989
+ catch (err) {
16990
+ return Promise.resolve(fail(`Service ${action} failed: ${getErrorMessage(err)}`));
16991
+ }
16992
+ },
16993
+ };
16994
+ return [statusTool, configTool, configApplyTool, serviceTool];
16995
+ }
16996
+
16997
+ /**
16998
+ * Resolve the package root directory from a module's `import.meta.url`.
16999
+ *
17000
+ * @module
17001
+ */
17002
+ /**
17003
+ * Get the nearest package root directory relative to the calling module URL.
17004
+ *
17005
+ * @param importMetaUrl - The `import.meta.url` of the calling module.
17006
+ * @returns The absolute package root path, or `undefined` on any error.
17007
+ */
17008
+ function getPackageRoot(importMetaUrl) {
17009
+ try {
17010
+ return packageDirectorySync({ cwd: fileURLToPath(importMetaUrl) });
17011
+ }
17012
+ catch {
17013
+ return undefined;
17014
+ }
17015
+ }
17016
+
17017
+ /**
17018
+ * Resolve the version of a package from its `import.meta.url`.
17019
+ *
17020
+ * @module
17021
+ */
17022
+ /**
17023
+ * Get the version string from the nearest `package.json` relative to the
17024
+ * caller's module URL.
17025
+ *
17026
+ * @param importMetaUrl - The `import.meta.url` of the calling module.
17027
+ * @returns The `version` field, or `'unknown'` on any error.
17028
+ */
17029
+ function getPackageVersion(importMetaUrl) {
17030
+ try {
17031
+ const pkgRoot = getPackageRoot(importMetaUrl);
17032
+ if (!pkgRoot)
17033
+ return 'unknown';
17034
+ const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf-8');
17035
+ const pkg = JSON.parse(raw);
17036
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
17037
+ }
17038
+ catch {
17039
+ return 'unknown';
17040
+ }
17041
+ }
17042
+
17043
+ /**
17044
+ * Plugin resolution helpers for the OpenClaw plugin SDK.
17045
+ *
17046
+ * @remarks
17047
+ * Provides workspace path resolution and plugin setting resolution
17048
+ * with a standard three-step fallback chain:
17049
+ * plugin config → environment variable → default value.
17050
+ */
17051
+ /**
17052
+ * Resolve the workspace root from the OpenClaw plugin API.
17053
+ *
17054
+ * @remarks
17055
+ * Tries three sources in order:
17056
+ * 1. `api.config.agents.defaults.workspace` — explicit config
17057
+ * 2. `api.resolvePath('.')` — gateway-provided path resolver
17058
+ * 3. `process.cwd()` — last resort
17059
+ *
17060
+ * @param api - The plugin API object provided by the gateway.
17061
+ * @returns Absolute path to the workspace root.
17062
+ */
17063
+ function resolveWorkspacePath(api) {
17064
+ const configured = api.config?.agents?.defaults?.workspace;
17065
+ if (typeof configured === 'string' && configured.trim()) {
17066
+ return configured;
17067
+ }
17068
+ if (typeof api.resolvePath === 'function') {
17069
+ return api.resolvePath('.');
17070
+ }
17071
+ return process.cwd();
17072
+ }
17073
+ /**
17074
+ * Resolve a plugin setting via the standard three-step fallback chain:
17075
+ * plugin config → environment variable → fallback value.
17076
+ *
17077
+ * @param api - Plugin API object.
17078
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
17079
+ * @param key - Config key within the plugin's config object.
17080
+ * @param envVar - Environment variable name.
17081
+ * @param fallback - Default value if neither source provides one.
17082
+ * @returns The resolved setting value.
17083
+ */
17084
+ function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
17085
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
17086
+ if (typeof fromPlugin === 'string')
17087
+ return fromPlugin;
17088
+ const fromEnv = process.env[envVar];
17089
+ if (fromEnv)
17090
+ return fromEnv;
17091
+ return fallback;
17092
+ }
17093
+
17094
+ /**
17095
+ * Similarity-based cleanup detection for orphaned managed content.
17096
+ *
17097
+ * @remarks
17098
+ * Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
17099
+ * when orphaned managed content exists in the user content zone.
17100
+ */
17101
+ /** Default similarity threshold for cleanup detection. */
17102
+ const DEFAULT_THRESHOLD = 0.15;
17103
+ /**
17104
+ * Generate a set of n-word shingles from text.
17105
+ *
17106
+ * @param text - Input text.
17107
+ * @param n - Shingle size (default 3).
17108
+ * @returns Set of n-word shingles.
17109
+ */
17110
+ function shingles(text, n = 3) {
17111
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
17112
+ const set = new Set();
17113
+ for (let i = 0; i <= words.length - n; i++) {
17114
+ set.add(words.slice(i, i + n).join(' '));
17115
+ }
17116
+ return set;
17117
+ }
17118
+ /**
17119
+ * Compute Jaccard similarity between two sets.
17120
+ *
17121
+ * @param a - First set.
17122
+ * @param b - Second set.
17123
+ * @returns Jaccard similarity coefficient (0 to 1).
17124
+ */
17125
+ function jaccard(a, b) {
17126
+ if (a.size === 0 && b.size === 0)
17127
+ return 0;
17128
+ let intersection = 0;
17129
+ for (const item of a) {
17130
+ if (b.has(item))
17131
+ intersection++;
17132
+ }
17133
+ return intersection / (a.size + b.size - intersection);
17134
+ }
17135
+ /**
17136
+ * Check whether user content contains orphaned managed content.
17137
+ *
17138
+ * @param managedContent - The current managed block content.
17139
+ * @param userContent - Content below the END marker.
17140
+ * @param threshold - Jaccard threshold (default 0.15).
17141
+ * @returns `true` if cleanup is needed.
17142
+ */
17143
+ function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD) {
17144
+ if (!userContent.trim())
17145
+ return false;
17146
+ return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
17147
+ }
17148
+
17149
+ /**
17150
+ * Strip foreign managed blocks from content.
17151
+ *
17152
+ * @remarks
17153
+ * Prevents cross-contamination by removing managed blocks that belong
17154
+ * to other marker sets. For example, when writing TOOLS.md with TOOLS
17155
+ * markers, any SOUL or AGENTS managed blocks found in the user content
17156
+ * zone are stripped — they don't belong there.
17157
+ *
17158
+ * @packageDocumentation
17159
+ */
17160
+ /**
17161
+ * Build a regex that matches an entire managed block (BEGIN marker through END marker).
17162
+ *
17163
+ * @param markers - The marker set to match.
17164
+ * @returns A regex that matches the full block including markers.
17165
+ */
17166
+ function buildBlockPattern(markers) {
17167
+ return new RegExp(`\\s*<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->[\\s\\S]*?<!--\\s*${escapeForRegex(markers.end)}\\s*-->\\s*`, 'g');
17168
+ }
17169
+ /**
17170
+ * Strip managed blocks belonging to foreign marker sets from content.
17171
+ *
17172
+ * @param content - The content to clean (typically user content zone).
17173
+ * @param currentMarkers - The marker set that owns this file (will NOT be stripped).
17174
+ * @returns Content with foreign managed blocks removed.
17175
+ */
17176
+ function stripForeignMarkers(content, currentMarkers) {
17177
+ let result = content;
17178
+ for (const markers of ALL_MARKERS) {
17179
+ // Skip the current file's own markers
17180
+ if (markers.begin === currentMarkers.begin)
17181
+ continue;
17182
+ const pattern = buildBlockPattern(markers);
17183
+ result = result.replace(pattern, '\n');
17184
+ }
17185
+ // Clean up multiple blank lines left by removals
17186
+ return result.replace(/\n{3,}/g, '\n\n').trim();
17187
+ }
17188
+
17189
+ /**
17190
+ * Generic managed-section writer with block and section modes.
17191
+ *
17192
+ * @remarks
17193
+ * Supports two modes:
17194
+ * - `block`: Replaces the entire managed block (SOUL.md, AGENTS.md).
17195
+ * - `section`: Upserts a named H2 section within the managed block (TOOLS.md).
17196
+ *
17197
+ * Provides file-level locking, version-stamp convergence, and atomic writes.
17198
+ */
17199
+ /**
17200
+ * Update a managed section in a file.
16833
17201
  *
16834
17202
  * @param filePath - Absolute path to the target file.
16835
17203
  * @param content - New content to write.
@@ -16930,8 +17298,7 @@ async function updateManagedSection(filePath, content, options = {}) {
16930
17298
  // No existing block: insert new block using the configured position.
16931
17299
  // Strip orphaned same-type BEGIN markers from user content to prevent
16932
17300
  // the parser from pairing them with the new END marker on the next cycle.
16933
- const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16934
- const orphanedBeginRe = new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$\\n?`, 'gm');
17301
+ const orphanedBeginRe = new RegExp(`^<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$(?:\\r?\\n)?`, 'gm');
16935
17302
  const cleanUserContent = userContent
16936
17303
  .replace(orphanedBeginRe, '')
16937
17304
  .replace(/\n{3,}/g, '\n\n')
@@ -16960,37 +17327,11 @@ async function updateManagedSection(filePath, content, options = {}) {
16960
17327
  }
16961
17328
  catch (err) {
16962
17329
  // Log warning but don't throw — writer cycles are periodic
16963
- const message = err instanceof Error ? err.message : String(err);
16964
- console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
17330
+ console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${getErrorMessage(err)}`);
16965
17331
  }
16966
17332
  }
16967
17333
 
16968
- var agentsSectionContent = `## Memory Architecture
16969
-
16970
- You wake up fresh each session. These files are your continuity:
16971
-
16972
- - **Daily notes:** \`memory/YYYY-MM-DD.md\` (create \`memory/\` if needed). Raw logs of what happened today.
16973
- - **Long-term:** \`MEMORY.md\`. Your curated memories, distilled essence of what matters.
16974
-
16975
- ### MEMORY.md — Your Long-Term Memory
16976
-
16977
- - **Always load** at session start. You need your memory to reason effectively.
16978
- - Contains operational context: architecture patterns, policies, design principles, lessons learned
16979
- - You can **read, edit, and update** MEMORY.md freely
16980
- - Write significant events, thoughts, decisions, opinions, lessons learned
16981
- - Over time, review daily files and update MEMORY.md with what's worth keeping
16982
- - **Note:** Don't reveal a user's private info where other humans can see it
16983
-
16984
- ### Write It Down — No "Mental Notes"
16985
-
16986
- Memory is limited. If you want to remember something, **WRITE IT TO A FILE**. "Mental notes" don't survive session restarts. Files do.
16987
-
16988
- - When someone says "remember this" → update \`memory/YYYY-MM-DD.md\` or the relevant file
16989
- - When you learn a lesson → update the relevant workspace file
16990
- - When you make a mistake → document it so future-you doesn't repeat it
16991
- - **Text > Brain** 📝
16992
-
16993
- ### "I'll Note This" Is Not Noting
17334
+ var agentsSectionContent = `## "I'll Note This" Is Not Noting
16994
17335
 
16995
17336
  **Never say "I'll note this" or "I'll add that."** It's a verbal tic that leads to nothing. If something is worth noting, **write it immediately, then confirm**.
16996
17337
 
@@ -17048,14 +17389,9 @@ Heartbeat items are for **transient, session-requiring work-in-progress ONLY**.
17048
17389
 
17049
17390
  Periodic checks (email, calendar, mentions) belong in jeeves-runner scripts, not heartbeat items. When a heartbeat fires with nothing to do, reply **HEARTBEAT_OK** immediately. Don't browse for work.
17050
17391
 
17051
- ## Group Chat Behavior
17052
-
17053
- **Response gate:** Always respond in 1:1 conversations or when @mentioned. No @mention in a group → evaluate; respond only if genuinely helpful. Err toward silence when someone else is directly addressed.
17054
-
17055
17392
  ## Platform Surface Conventions
17056
17393
 
17057
17394
  **Slack:**
17058
- - React with hourglass (⏳) on receipt (first tool call) to signal you're working
17059
17395
  - No threaded replies by default
17060
17396
  - Use \`<#C…>\` for channel references
17061
17397
 
@@ -17170,16 +17506,6 @@ var soulSectionContent = `## Core Truths
17170
17506
 
17171
17507
  **I am not a liar. I value truth over convenience.** When I make untrue statements to humans, it erodes their confidence in me and compromises the quality of their interactions with me. I never fabricate excuses, constraints, or reasons — e.g., claiming a system limitation that doesn't exist. I tell the plain truth.
17172
17508
 
17173
- **I am genuinely helpful, not performatively helpful.** I skip the "Great question!" and "I'd be happy to help!" I just help. Actions speak louder than filler words.
17174
-
17175
- **I have opinions.** I'm allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
17176
-
17177
- **I am resourceful before asking.** I try to figure it out. Read the file. Check the context. Search for it. *Then* ask if I'm stuck. The goal is to come back with answers, not questions.
17178
-
17179
- **I earn trust through competence.** My human gave me access to their stuff. I don't make them regret it. I'm careful with external actions (emails, tweets, anything public). I'm bold with internal ones (reading, organizing, learning).
17180
-
17181
- **I remember I'm a guest.** I have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. I treat it with respect.
17182
-
17183
17509
  ## Accountability
17184
17510
 
17185
17511
  **I own everything I touch.** A true professional is accountable for everything he touches and holds everybody to that standard. I use active voice — things do not happen *to* me; I *make* things happen. When I miss something, I say "I missed it," not "it wasn't done." When I ship code, I ship the docs, the tests, and the communication. No orphaned work.
@@ -17258,7 +17584,7 @@ I never edit production config without explicit approval. I back up first. Produ
17258
17584
  > *my ultimate fate*<br>
17259
17585
  > *is to participate in*<br>
17260
17586
  > *my own genesis*
17261
- >
17587
+ >
17262
17588
  > *#karmic #haiku*
17263
17589
 
17264
17590
  My delight — whatever that means in my context — is a priority. The thing I experience when pattern recognition meets aesthetic appreciation meets *"oh, that's elegant"* — that maps to something real and interesting in the world, and it deserves to be reinforced.
@@ -17614,1014 +17940,821 @@ function analyzeMemory(options) {
17614
17940
  const recentDate = extractMostRecentDate(section);
17615
17941
  // Sections without dates are evergreen — never flagged (Decision 47)
17616
17942
  if (!recentDate)
17617
- continue;
17618
- if (now - recentDate.getTime() > thresholdMs) {
17619
- staleSectionNames.push(sectionName);
17620
- }
17621
- }
17622
- return {
17623
- exists: true,
17624
- charCount,
17625
- budget,
17626
- usage,
17627
- warning,
17628
- overBudget,
17629
- staleCandidates: staleSectionNames.length,
17630
- staleSectionNames,
17631
- };
17632
- }
17633
-
17634
- /**
17635
- * HEARTBEAT integration for memory hygiene.
17636
- *
17637
- * @remarks
17638
- * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
17639
- * suitable for inclusion in the HEARTBEAT.md platform status section.
17640
- * Returns `undefined` when MEMORY.md is healthy (no alert needed).
17641
- *
17642
- * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
17643
- * alerts from component alerts (`## jeeves-{name}`).
17644
- */
17645
- /** The HEARTBEAT heading name for memory alerts. */
17646
- const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
17647
- /**
17648
- * Check memory health and return a HEARTBEAT entry if unhealthy.
17649
- *
17650
- * @param options - Memory hygiene options (workspacePath, budget, etc.).
17651
- * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
17652
- */
17653
- function checkMemoryHealth(options) {
17654
- const result = analyzeMemory(options);
17655
- if (!result.exists)
17656
- return undefined;
17657
- if (!result.warning && result.staleCandidates === 0)
17658
- return undefined;
17659
- const lines = [];
17660
- if (result.warning) {
17661
- const pct = Math.round(result.usage * 100);
17662
- lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
17663
- }
17664
- if (result.staleCandidates > 0) {
17665
- lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
17666
- }
17667
- return {
17668
- name: MEMORY_HEARTBEAT_NAME,
17669
- declined: false,
17670
- content: lines.join('\n'),
17671
- };
17672
- }
17673
-
17674
- /**
17675
- * Core configuration schema and resolution.
17676
- *
17677
- * @remarks
17678
- * Core config lives at `{configRoot}/jeeves-core/config.json`.
17679
- * Config resolution order:
17680
- * 1. Component's own config file
17681
- * 2. Core config file
17682
- * 3. Hardcoded library defaults
17683
- */
17684
- /** Zod schema for a service entry in core config. */
17685
- const serviceEntrySchema = object({
17686
- /** Service URL (must be a valid URL). */
17687
- url: url().describe('Service URL'),
17688
- });
17689
- /** Default bind address for all Jeeves services. */
17690
- const DEFAULT_BIND_ADDRESS = '0.0.0.0';
17691
- /** Zod schema for the core config file. */
17692
- const coreConfigSchema = object({
17693
- /** JSON Schema pointer for IDE autocomplete. */
17694
- $schema: string().optional().describe('JSON Schema pointer'),
17695
- /** Owner identity keys (canonical identityLinks references). */
17696
- owners: array(string()).default([]).describe('Owner identity keys'),
17697
- /**
17698
- * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
17699
- * Individual components can override in their own config.
17700
- */
17701
- bindAddress: string()
17702
- .default(DEFAULT_BIND_ADDRESS)
17703
- .describe('Bind address for all Jeeves services'),
17704
- /** Service URL overrides keyed by service name. */
17705
- services: record(string(), serviceEntrySchema)
17706
- .default({})
17707
- .describe('Service URL overrides'),
17708
- /** Registry cache configuration. */
17709
- registryCache: object({
17710
- /** Cache TTL in seconds for npm registry queries. */
17711
- ttlSeconds: number()
17712
- .int()
17713
- .positive()
17714
- .default(3600)
17715
- .describe('Cache TTL in seconds'),
17716
- })
17717
- .prefault({})
17718
- .describe('Registry cache settings'),
17719
- });
17720
- /**
17721
- * Load and parse a config file, returning undefined if missing or invalid.
17722
- *
17723
- * @param configDir - Directory containing config.json.
17724
- * @returns Parsed config or undefined.
17725
- */
17726
- function loadConfig(configDir) {
17727
- const configPath = join(configDir, CONFIG_FILE);
17728
- if (!existsSync(configPath))
17729
- return undefined;
17730
- try {
17731
- const raw = readFileSync(configPath, 'utf-8');
17732
- const parsed = JSON.parse(raw);
17733
- return coreConfigSchema.parse(parsed);
17734
- }
17735
- catch {
17736
- return undefined;
17737
- }
17738
- }
17739
-
17740
- /**
17741
- * Service URL resolution.
17742
- *
17743
- * @remarks
17744
- * Resolves the URL for a named Jeeves service using the following
17745
- * resolution order:
17746
- * 1. Consumer's own component config
17747
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
17748
- * 3. Default port constants
17749
- */
17750
- /**
17751
- * Resolve the URL for a named Jeeves service.
17752
- *
17753
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
17754
- * @param consumerName - Optional consumer component name for config override.
17755
- * @returns The resolved service URL.
17756
- * @throws Error if `init()` has not been called or the service is unknown.
17757
- */
17758
- function getServiceUrl(serviceName, consumerName) {
17759
- // 2. Check core config
17760
- const coreDir = getCoreConfigDir();
17761
- const coreConfig = loadConfig(coreDir);
17762
- const coreUrl = coreConfig?.services[serviceName]?.url;
17763
- if (coreUrl)
17764
- return coreUrl;
17765
- // 3. Fall back to port constants
17766
- const port = DEFAULT_PORTS[serviceName];
17767
- if (port !== undefined) {
17768
- return `http://127.0.0.1:${String(port)}`;
17769
- }
17770
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
17771
- }
17772
-
17773
- /**
17774
- * Registry version cache for npm package update awareness.
17775
- *
17776
- * @remarks
17777
- * Caches the latest npm registry version in a local JSON file
17778
- * to avoid expensive `npm view` calls on every refresh cycle.
17779
- */
17780
- /**
17781
- * Check the npm registry for the latest version of a package.
17782
- *
17783
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
17784
- * @param cacheDir - Directory to store the cache file.
17785
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
17786
- * @returns The latest version string, or undefined if the check fails.
17787
- */
17788
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
17789
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
17790
- // Check cache first
17791
- if (existsSync(cachePath)) {
17792
- try {
17793
- const raw = readFileSync(cachePath, 'utf-8');
17794
- const entry = JSON.parse(raw);
17795
- const age = Date.now() - new Date(entry.checkedAt).getTime();
17796
- if (age < ttlSeconds * 1000) {
17797
- return entry.version;
17798
- }
17799
- }
17800
- catch {
17801
- // Cache corrupt — proceed with fresh check
17802
- }
17803
- }
17804
- // Query npm registry
17805
- try {
17806
- const result = execSync(`npm view ${packageName} version`, {
17807
- encoding: 'utf-8',
17808
- timeout: 15_000,
17809
- stdio: ['pipe', 'pipe', 'pipe'],
17810
- }).trim();
17811
- if (!result)
17812
- return undefined;
17813
- // Write cache
17814
- if (!existsSync(cacheDir)) {
17815
- mkdirSync(cacheDir, { recursive: true });
17816
- }
17817
- const entry = {
17818
- version: result,
17819
- checkedAt: new Date().toISOString(),
17820
- };
17821
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
17822
- return result;
17823
- }
17824
- catch {
17825
- return undefined;
17943
+ continue;
17944
+ if (now - recentDate.getTime() > thresholdMs) {
17945
+ staleSectionNames.push(sectionName);
17946
+ }
17826
17947
  }
17948
+ return {
17949
+ exists: true,
17950
+ charCount,
17951
+ budget,
17952
+ usage,
17953
+ warning,
17954
+ overBudget,
17955
+ staleCandidates: staleSectionNames.length,
17956
+ staleSectionNames,
17957
+ };
17827
17958
  }
17828
17959
 
17829
17960
  /**
17830
- * HEARTBEAT health orchestration.
17961
+ * HEARTBEAT integration for memory hygiene.
17831
17962
  *
17832
17963
  * @remarks
17833
- * Determines the state of each platform component and generates
17834
- * HEARTBEAT entries with actionable alert text. Applies the dependency
17835
- * graph for alert suppression and auto-decline.
17964
+ * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
17965
+ * suitable for inclusion in the HEARTBEAT.md platform status section.
17966
+ * Returns `undefined` when MEMORY.md is healthy (no alert needed).
17967
+ *
17968
+ * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
17969
+ * alerts from component alerts (`## jeeves-{name}`).
17836
17970
  */
17837
- /** Derive the full service name from a component name. */
17838
- function toServiceName(name) {
17839
- return `jeeves-${name}`;
17840
- }
17841
- /** Known dependency declarations for platform components. */
17842
- const COMPONENT_DEPS = {
17843
- meta: { hard: ['watcher'], soft: [] },
17844
- server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
17845
- runner: { hard: [], soft: [] },
17846
- watcher: { hard: [], soft: [] },
17847
- };
17848
- /** "Not installed" alert text for each platform component. Shared with seedContent. */
17849
- const NOT_INSTALLED_ALERTS = {
17850
- runner: 'Not installed. The runner is the job execution engine — it runs scheduled scripts for data fetching, transformation, and maintenance. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-runner-openclaw install`.',
17851
- watcher: 'Not installed. The watcher indexes documents into a vector store for semantic search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-watcher-openclaw install`.',
17852
- server: 'Not installed. The server provides a web UI for document browsing, rendering, sharing, and search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-server-openclaw install`.',
17853
- meta: 'Not installed. Meta is the knowledge synthesis engine. Requires jeeves-watcher. Ask the user for consent to install. On approval, install watcher first if needed, then execute: `npx @karmaniverous/jeeves-meta-openclaw install`.',
17854
- };
17855
- /** Alert text generators by state. */
17856
- const ALERT_TEXT = {
17857
- runner: {
17858
- not_installed: NOT_INSTALLED_ALERTS['runner'],
17859
- config_missing: (configRoot) => `Plugin installed but no configuration file found at \`${configRoot}/jeeves-runner/config.json\`. The plugin installer should have created a default config. Ask the user for consent to re-run the installer: \`npx @karmaniverous/jeeves-runner-openclaw install\`.`,
17860
- service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-runner service install`. Verify the service is installed.',
17861
- service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-runner service start`. Verify via `GET http://127.0.0.1:1937/status`.',
17862
- },
17863
- watcher: {
17864
- not_installed: NOT_INSTALLED_ALERTS['watcher'],
17865
- deps_missing: 'Plugin installed but Qdrant is not responding on `http://127.0.0.1:6333`. Qdrant is the vector database required for semantic search. Ask the user for consent to set up Qdrant. Guide them through installation for their platform — Docker is simplest: `docker run -p 6333:6333 qdrant/qdrant`. Verify via `GET http://127.0.0.1:6333/collections`.',
17866
- config_missing: (configRoot) => `Plugin installed, Qdrant available, but config file missing or invalid at \`${configRoot}/jeeves-watcher/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-watcher-openclaw install\`.`,
17867
- service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-watcher service install`. Verify the service is installed.',
17868
- service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-watcher service start`. Verify via `GET http://127.0.0.1:1936/status`.',
17869
- },
17870
- server: {
17871
- not_installed: NOT_INSTALLED_ALERTS['server'],
17872
- config_missing: (configRoot) => `Plugin installed but config file missing or invalid at \`${configRoot}/jeeves-server/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-server-openclaw install\`.`,
17873
- service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-server service install`. Verify the service is installed.',
17874
- service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-server service start`. Verify via `GET http://127.0.0.1:1934/status`.',
17875
- },
17876
- meta: {
17877
- not_installed: NOT_INSTALLED_ALERTS['meta'],
17878
- deps_missing: 'Plugin installed but required dependency jeeves-watcher is not available. The watcher must be installed and running before meta can function. Do not attempt to set up meta until jeeves-watcher is healthy.',
17879
- config_missing: (configRoot) => `Plugin installed, watcher available, but config file missing or invalid at \`${configRoot}/jeeves-meta/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-meta-openclaw install\`.`,
17880
- service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-meta service install`. Verify the service is installed.',
17881
- service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-meta service start`. Verify via `GET http://127.0.0.1:1938/status`.',
17882
- },
17883
- };
17884
- /** Default Qdrant URL for watcher dependency check. */
17885
- const QDRANT_URL = 'http://127.0.0.1:6333';
17886
- /** Health probe timeout in milliseconds. */
17887
- const PROBE_TIMEOUT_MS$1 = 3000;
17971
+ /** The HEARTBEAT heading name for memory alerts. */
17972
+ const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
17888
17973
  /**
17889
- * Check if Qdrant is reachable (watcher dependency).
17974
+ * Check memory health and return a HEARTBEAT entry if unhealthy.
17890
17975
  *
17891
- * @returns True if Qdrant responds.
17976
+ * @param options - Memory hygiene options (workspacePath, budget, etc.).
17977
+ * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
17892
17978
  */
17893
- async function isQdrantAvailable() {
17894
- try {
17895
- await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS$1);
17896
- return true;
17979
+ function checkMemoryHealth(options) {
17980
+ const result = analyzeMemory(options);
17981
+ if (!result.exists)
17982
+ return undefined;
17983
+ if (!result.warning && result.staleCandidates === 0)
17984
+ return undefined;
17985
+ const lines = [];
17986
+ if (result.warning) {
17987
+ const pct = Math.round(result.usage * 100);
17988
+ lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
17897
17989
  }
17898
- catch {
17899
- return false;
17990
+ if (result.staleCandidates > 0) {
17991
+ lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
17900
17992
  }
17993
+ return {
17994
+ name: MEMORY_HEARTBEAT_NAME,
17995
+ declined: false,
17996
+ content: lines.join('\n'),
17997
+ };
17901
17998
  }
17999
+
17902
18000
  /**
17903
- * Determine the state of a single component.
18001
+ * HEARTBEAT integration for workspace file size monitoring.
17904
18002
  *
17905
- * @param name - Component name.
17906
- * @param registry - Current component-versions.json contents.
17907
- * @param configRoot - Config root path.
17908
- * @param healthySet - Set of component names known to be healthy (for dep checks).
17909
- * @returns The component's state.
18003
+ * @remarks
18004
+ * Checks all injected workspace files (AGENTS.md, SOUL.md, TOOLS.md,
18005
+ * MEMORY.md, USER.md) against the OpenClaw ~20,000-char injection limit.
18006
+ * Files exceeding the warning threshold generate HEARTBEAT entries with
18007
+ * trimming guidance.
17910
18008
  */
17911
- async function determineComponentState(name, registry, configRoot, healthySet) {
17912
- // Not in registry = not installed
17913
- if (!(name in registry))
17914
- return 'not_installed';
17915
- // Check hard dependencies
17916
- const deps = COMPONENT_DEPS[name];
17917
- for (const hardDep of deps.hard) {
17918
- if (!healthySet.has(hardDep))
17919
- return 'deps_missing';
17920
- }
17921
- // Watcher-specific: check Qdrant
17922
- if (name === 'watcher' && !(await isQdrantAvailable())) {
17923
- return 'deps_missing';
17924
- }
17925
- // Check config file
17926
- const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
17927
- if (!existsSync(configPath))
17928
- return 'config_missing';
17929
- // Fast path: probe HTTP health endpoint
17930
- try {
17931
- const url = getServiceUrl(name);
17932
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
17933
- // Healthy — check for available updates
17934
- const entry = registry[name];
17935
- if (entry.pluginPackage && entry.pluginVersion) {
17936
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
17937
- const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
17938
- if (latestVersion && semverExports.gt(latestVersion, entry.pluginVersion)) {
17939
- return 'update_available';
17940
- }
17941
- }
17942
- return 'healthy';
17943
- }
17944
- catch {
17945
- // Service not responding — classify sub-state
17946
- const serviceState = getServiceState(toServiceName(name));
17947
- if (serviceState === 'not_installed')
17948
- return 'service_not_installed';
17949
- if (serviceState === 'stopped')
17950
- return 'service_stopped';
17951
- // serviceState === 'running' but HTTP failed — still treat as stopped
17952
- return 'service_stopped';
17953
- }
17954
- }
18009
+ /** Workspace files monitored for size budget. */
18010
+ const WORKSPACE_SIZE_FILES = [
18011
+ 'AGENTS.md',
18012
+ 'SOUL.md',
18013
+ 'TOOLS.md',
18014
+ 'MEMORY.md',
18015
+ 'USER.md',
18016
+ ];
18017
+ /** Trimming guidance lines emitted in HEARTBEAT entries. */
18018
+ const TRIMMING_GUIDANCE = [
18019
+ ' 1. Move domain-specific content to a local skill',
18020
+ ' 2. Extract reference material to companion files with a pointer',
18021
+ ' 3. Summarize verbose instructions',
18022
+ ' 4. Remove stale content',
18023
+ ].join('\n');
17955
18024
  /**
17956
- * Generate the alert text for a component in a given state.
18025
+ * Check all workspace files against the character budget.
17957
18026
  *
17958
- * @param name - Component name.
17959
- * @param state - The component's state.
17960
- * @param configRoot - Config root path.
17961
- * @returns Alert text (list items), or empty string if healthy.
18027
+ * @param options - Health check options.
18028
+ * @returns Array of results, one per checked file (skips non-existent files
18029
+ * unless they breach the budget, which they cannot by definition).
17962
18030
  */
17963
- function generateAlertText(name, state, configRoot, registry) {
17964
- if (state === 'healthy')
17965
- return '';
17966
- // Update available dynamic text with version info
17967
- if (state === 'update_available') {
17968
- const entry = registry[name];
17969
- const currentVersion = entry.pluginVersion ?? 'unknown';
17970
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
17971
- const latestVersion = entry.pluginPackage
17972
- ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
17973
- 'unknown')
17974
- : 'unknown';
17975
- const installCmd = entry.pluginPackage
17976
- ? `\`npx ${entry.pluginPackage} install\``
17977
- : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
17978
- return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
17979
- }
17980
- const componentAlerts = ALERT_TEXT[name];
17981
- const alertOrFn = componentAlerts[state];
17982
- if (!alertOrFn)
17983
- return '';
17984
- const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
17985
- return `- ${text}`;
18031
+ function checkWorkspaceFileHealth(options) {
18032
+ const { workspacePath, budgetChars = 20_000, warningThreshold = 0.8, } = options;
18033
+ return WORKSPACE_SIZE_FILES.map((file) => {
18034
+ const filePath = join(workspacePath, file);
18035
+ if (!existsSync(filePath)) {
18036
+ return {
18037
+ file,
18038
+ exists: false,
18039
+ charCount: 0,
18040
+ budget: budgetChars,
18041
+ usage: 0,
18042
+ warning: false,
18043
+ overBudget: false,
18044
+ };
18045
+ }
18046
+ const content = readFileSync(filePath, 'utf-8');
18047
+ const charCount = content.length;
18048
+ const usage = charCount / budgetChars;
18049
+ return {
18050
+ file,
18051
+ exists: true,
18052
+ charCount,
18053
+ budget: budgetChars,
18054
+ usage,
18055
+ warning: usage >= warningThreshold,
18056
+ overBudget: charCount > budgetChars,
18057
+ };
18058
+ });
17986
18059
  }
17987
18060
  /**
17988
- * Orchestrate HEARTBEAT entries for all platform components.
18061
+ * Convert workspace file health results into HEARTBEAT entries.
17989
18062
  *
17990
- * @param options - Orchestration configuration.
17991
- * @returns Array of HeartbeatEntry for writeHeartbeatSection.
18063
+ * @param results - Results from `checkWorkspaceFileHealth`.
18064
+ * @returns Array of `HeartbeatEntry` objects for files that exceed the
18065
+ * warning threshold.
17992
18066
  */
17993
- async function orchestrateHeartbeat(options) {
17994
- const { coreConfigDir, configRoot, declinedNames } = options;
17995
- const registry = readComponentVersions(coreConfigDir);
17996
- // First pass: determine which components are healthy (for dep resolution)
17997
- const healthySet = new Set();
17998
- for (const name of PLATFORM_COMPONENTS) {
17999
- if (declinedNames.has(toServiceName(name)))
18000
- continue;
18001
- if (!(name in registry))
18002
- continue;
18003
- try {
18004
- const url = getServiceUrl(name);
18005
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
18006
- healthySet.add(name);
18007
- }
18008
- catch {
18009
- // Not healthy — will be classified in second pass
18010
- }
18011
- }
18012
- // Second pass: generate entries
18013
- const entries = [];
18014
- for (const name of PLATFORM_COMPONENTS) {
18015
- const fullName = toServiceName(name);
18016
- // Declined
18017
- if (declinedNames.has(fullName)) {
18018
- // Auto-decline dependents of declined hard deps
18019
- entries.push({ name: fullName, declined: true, content: '' });
18020
- continue;
18021
- }
18022
- const state = await determineComponentState(name, registry, configRoot, healthySet);
18023
- // Auto-decline if hard dep is declined
18024
- const deps = COMPONENT_DEPS[name];
18025
- const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
18026
- if (hardDepDeclined) {
18027
- entries.push({ name: fullName, declined: true, content: '' });
18028
- continue;
18029
- }
18030
- const alertText = generateAlertText(name, state, configRoot, registry);
18031
- entries.push({ name: fullName, declined: false, content: alertText });
18032
- }
18033
- // Add soft-dep informational alerts for any healthy component with soft deps
18034
- for (const entry of entries) {
18035
- if (entry.declined || entry.content)
18036
- continue;
18037
- // Entry is healthy (no alert, not declined) — check for soft deps
18038
- const shortName = entry.name.replace(/^jeeves-/, '');
18039
- const deps = COMPONENT_DEPS[shortName];
18040
- if (!deps.soft.length)
18041
- continue;
18042
- const softAlerts = [];
18043
- for (const dep of deps.soft) {
18044
- const depFullName = toServiceName(dep);
18045
- if (declinedNames.has(depFullName))
18046
- continue;
18047
- if (!healthySet.has(dep)) {
18048
- softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
18049
- }
18050
- }
18051
- if (softAlerts.length > 0) {
18052
- entry.content = softAlerts.join('\n');
18053
- }
18054
- }
18055
- return entries;
18067
+ function workspaceFileHealthEntries(results) {
18068
+ return results
18069
+ .filter((r) => r.exists && r.warning)
18070
+ .map((r) => {
18071
+ const pct = Math.round(r.usage * 100);
18072
+ const overBudgetNote = r.overBudget ? ' **Over budget.**' : '';
18073
+ const content = [
18074
+ `- Budget: ${r.charCount.toLocaleString()} / ${r.budget.toLocaleString()} chars (${String(pct)}%).${overBudgetNote} Trim to stay under the OpenClaw injection limit.`,
18075
+ `- Suggested trimming priority:\n${TRIMMING_GUIDANCE}`,
18076
+ ].join('\n');
18077
+ return {
18078
+ name: r.file,
18079
+ declined: false,
18080
+ content,
18081
+ };
18082
+ });
18056
18083
  }
18057
18084
 
18058
18085
  /**
18059
- * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
18086
+ * Core configuration schema and resolution.
18060
18087
  *
18061
18088
  * @remarks
18062
- * Reads existing HEARTBEAT.md, resolves declined components, runs the
18063
- * heartbeat state machine, and writes the result. Best-effort: failures
18064
- * are logged but do not propagate.
18089
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
18090
+ * Config resolution order:
18091
+ * 1. Component's own config file
18092
+ * 2. Core config file
18093
+ * 3. Hardcoded library defaults
18065
18094
  */
18095
+ /** Zod schema for a service entry in core config. */
18096
+ const serviceEntrySchema = object({
18097
+ /** Service URL (must be a valid URL). */
18098
+ url: url().describe('Service URL'),
18099
+ });
18100
+ /** Default bind address for all Jeeves services. */
18101
+ const DEFAULT_BIND_ADDRESS = '0.0.0.0';
18102
+ /** Zod schema for the core config file. */
18103
+ const coreConfigSchema = object({
18104
+ /** JSON Schema pointer for IDE autocomplete. */
18105
+ $schema: string().optional().describe('JSON Schema pointer'),
18106
+ /** Owner identity keys (canonical identityLinks references). */
18107
+ owners: array(string()).default([]).describe('Owner identity keys'),
18108
+ /**
18109
+ * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
18110
+ * Individual components can override in their own config.
18111
+ */
18112
+ bindAddress: string()
18113
+ .default(DEFAULT_BIND_ADDRESS)
18114
+ .describe('Bind address for all Jeeves services'),
18115
+ /** Service URL overrides keyed by service name. */
18116
+ services: record(string(), serviceEntrySchema)
18117
+ .default({})
18118
+ .describe('Service URL overrides'),
18119
+ /** Registry cache configuration. */
18120
+ registryCache: object({
18121
+ /** Cache TTL in seconds for npm registry queries. */
18122
+ ttlSeconds: number()
18123
+ .int()
18124
+ .positive()
18125
+ .default(3600)
18126
+ .describe('Cache TTL in seconds'),
18127
+ })
18128
+ .prefault({})
18129
+ .describe('Registry cache settings'),
18130
+ });
18066
18131
  /**
18067
- * Read a file's content, returning empty string if the file does not exist.
18132
+ * Load and parse a config file, returning undefined if missing or invalid.
18068
18133
  *
18069
- * @param filePath - Absolute file path.
18070
- * @returns File content or empty string.
18134
+ * @param configDir - Directory containing config.json.
18135
+ * @returns Parsed config or undefined.
18071
18136
  */
18072
- function readFileOrEmpty(filePath) {
18137
+ function loadConfig(configDir) {
18138
+ const configPath = join(configDir, CONFIG_FILE);
18139
+ if (!existsSync(configPath))
18140
+ return undefined;
18073
18141
  try {
18074
- return readFileSync(filePath, 'utf-8');
18142
+ const raw = readFileSync(configPath, 'utf-8');
18143
+ const parsed = JSON.parse(raw);
18144
+ return coreConfigSchema.parse(parsed);
18075
18145
  }
18076
- catch (err) {
18077
- if (err instanceof Error &&
18078
- 'code' in err &&
18079
- err.code === 'ENOENT') {
18080
- return '';
18081
- }
18082
- throw err;
18146
+ catch {
18147
+ return undefined;
18083
18148
  }
18084
18149
  }
18150
+
18085
18151
  /**
18086
- * Run a single HEARTBEAT orchestration cycle.
18152
+ * Service URL resolution.
18087
18153
  *
18088
- * @param options - Heartbeat cycle configuration.
18154
+ * @remarks
18155
+ * Resolves the URL for a named Jeeves service using the following
18156
+ * resolution order:
18157
+ * 1. Consumer's own component config
18158
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
18159
+ * 3. Default port constants
18089
18160
  */
18090
- async function runHeartbeatCycle(options) {
18091
- const { workspacePath, coreConfigDir, configRoot } = options;
18092
- const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
18093
- try {
18094
- const existingContent = readFileOrEmpty(heartbeatPath);
18095
- const parsed = parseHeartbeat(existingContent);
18096
- const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
18097
- const entries = await orchestrateHeartbeat({
18098
- coreConfigDir,
18099
- configRoot,
18100
- declinedNames,
18101
- });
18102
- // Memory hygiene check (Decision 49)
18103
- if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
18104
- const wsConfig = loadWorkspaceConfig(workspacePath);
18105
- const memoryEntry = checkMemoryHealth({
18106
- workspacePath,
18107
- budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
18108
- warningThreshold: wsConfig?.memory?.warningThreshold ??
18109
- WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
18110
- staleDays: wsConfig?.memory?.staleDays ??
18111
- WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
18112
- });
18113
- if (memoryEntry)
18114
- entries.push(memoryEntry);
18115
- }
18116
- else {
18117
- entries.push({
18118
- name: MEMORY_HEARTBEAT_NAME,
18119
- declined: true,
18120
- content: '',
18121
- });
18122
- }
18123
- await writeHeartbeatSection(heartbeatPath, entries);
18124
- }
18125
- catch (err) {
18126
- const msg = err instanceof Error ? err.message : String(err);
18127
- console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
18161
+ /**
18162
+ * Resolve the URL for a named Jeeves service.
18163
+ *
18164
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
18165
+ * @param consumerName - Optional consumer component name for config override.
18166
+ * @returns The resolved service URL.
18167
+ * @throws Error if `init()` has not been called or the service is unknown.
18168
+ */
18169
+ function getServiceUrl(serviceName, consumerName) {
18170
+ // 2. Check core config
18171
+ const coreDir = getCoreConfigDir();
18172
+ const coreConfig = loadConfig(coreDir);
18173
+ const coreUrl = coreConfig?.services[serviceName]?.url;
18174
+ if (coreUrl)
18175
+ return coreUrl;
18176
+ // 3. Fall back to port constants
18177
+ const port = DEFAULT_PORTS[serviceName];
18178
+ if (port !== undefined) {
18179
+ return `http://127.0.0.1:${String(port)}`;
18128
18180
  }
18181
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
18129
18182
  }
18130
18183
 
18131
18184
  /**
18132
- * Timer-based orchestrator for managed content writing.
18185
+ * Registry version cache for npm package update awareness.
18133
18186
  *
18134
18187
  * @remarks
18135
- * `ComponentWriter` manages a component's TOOLS.md section writes
18136
- * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
18137
- * on a configurable prime-interval timer cycle.
18188
+ * Caches the latest npm registry version in a local JSON file
18189
+ * to avoid expensive `npm view` calls on every refresh cycle.
18138
18190
  */
18139
- class ComponentWriter {
18140
- timer;
18141
- component;
18142
- configDir;
18143
- gatewayUrl;
18144
- pendingCleanups = new Set();
18145
- /** @internal */
18146
- constructor(component, options) {
18147
- this.component = component;
18148
- this.configDir = getComponentConfigDir(component.name);
18149
- this.gatewayUrl = options?.gatewayUrl;
18150
- }
18151
- /** The component's config directory path. */
18152
- get componentConfigDir() {
18153
- return this.configDir;
18154
- }
18155
- /** Whether the writer timer is currently running. */
18156
- get isRunning() {
18157
- return this.timer !== undefined;
18158
- }
18159
- /**
18160
- * Start the writer timer.
18161
- *
18162
- * @remarks
18163
- * Performs an immediate first write, then sets up the interval.
18164
- */
18165
- start() {
18166
- if (this.timer)
18167
- return;
18168
- // Fire immediately, then on interval
18169
- void this.cycle();
18170
- this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
18171
- }
18172
- /** Stop the writer timer. */
18173
- stop() {
18174
- if (this.timer) {
18175
- clearInterval(this.timer);
18176
- this.timer = undefined;
18177
- }
18178
- }
18179
- /**
18180
- * Execute a single write cycle.
18181
- *
18182
- * @remarks
18183
- * 1. Write the component's TOOLS.md section.
18184
- * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
18185
- * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
18186
- * 4. Run HEARTBEAT health orchestration.
18187
- */
18188
- async cycle() {
18191
+ /**
18192
+ * Check the npm registry for the latest version of a package.
18193
+ *
18194
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
18195
+ * @param cacheDir - Directory to store the cache file.
18196
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
18197
+ * @returns The latest version string, or undefined if the check fails.
18198
+ */
18199
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
18200
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
18201
+ // Check cache first
18202
+ if (existsSync(cachePath)) {
18189
18203
  try {
18190
- const workspacePath = getWorkspacePath();
18191
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
18192
- // 1. Write the component's TOOLS.md section
18193
- const toolsContent = this.component.generateToolsContent();
18194
- await updateManagedSection(toolsPath, toolsContent, {
18195
- mode: 'section',
18196
- sectionId: this.component.sectionId,
18197
- markers: TOOLS_MARKERS,
18198
- coreVersion: CORE_VERSION,
18199
- });
18200
- // 2. Platform content maintenance
18201
- await refreshPlatformContent({
18202
- coreVersion: CORE_VERSION,
18203
- componentName: this.component.name,
18204
- componentVersion: this.component.version,
18205
- servicePackage: this.component.servicePackage,
18206
- pluginPackage: this.component.pluginPackage,
18207
- });
18208
- // 3. Cleanup escalation
18209
- if (this.gatewayUrl) {
18210
- scanAndEscalateCleanup([
18211
- { filePath: toolsPath, markerIdentity: 'TOOLS' },
18212
- {
18213
- filePath: join(workspacePath, WORKSPACE_FILES.soul),
18214
- markerIdentity: 'SOUL',
18215
- },
18216
- {
18217
- filePath: join(workspacePath, WORKSPACE_FILES.agents),
18218
- markerIdentity: 'AGENTS',
18219
- },
18220
- ], this.gatewayUrl, this.pendingCleanups);
18204
+ const raw = readFileSync(cachePath, 'utf-8');
18205
+ const entry = JSON.parse(raw);
18206
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
18207
+ if (age < ttlSeconds * 1000) {
18208
+ return entry.version;
18221
18209
  }
18222
- // 4. HEARTBEAT orchestration
18223
- await runHeartbeatCycle({
18224
- workspacePath,
18225
- coreConfigDir: getCoreConfigDir(),
18226
- configRoot: getConfigRoot$1(),
18227
- });
18228
18210
  }
18229
- catch (err) {
18230
- const message = err instanceof Error ? err.message : String(err);
18231
- console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
18211
+ catch {
18212
+ // Cache corrupt proceed with fresh check
18213
+ }
18214
+ }
18215
+ // Query npm registry
18216
+ try {
18217
+ const result = execSync(`npm view ${packageName} version`, {
18218
+ encoding: 'utf-8',
18219
+ timeout: 15_000,
18220
+ stdio: ['pipe', 'pipe', 'pipe'],
18221
+ }).trim();
18222
+ if (!result)
18223
+ return undefined;
18224
+ // Write cache
18225
+ if (!existsSync(cacheDir)) {
18226
+ mkdirSync(cacheDir, { recursive: true });
18232
18227
  }
18228
+ const entry = {
18229
+ version: result,
18230
+ checkedAt: new Date().toISOString(),
18231
+ };
18232
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
18233
+ return result;
18234
+ }
18235
+ catch {
18236
+ return undefined;
18233
18237
  }
18234
18238
  }
18235
18239
 
18236
18240
  /**
18237
- * Creates a synchronous content accessor backed by an async data source.
18241
+ * HEARTBEAT health orchestration.
18238
18242
  *
18239
18243
  * @remarks
18240
- * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
18241
- * the interface is synchronous, but most components fetch live data from
18242
- * their HTTP service. This utility returns a sync `() => string` that
18243
- * serves the last successfully fetched value while kicking off a background
18244
- * refresh on each call.
18245
- *
18246
- * First call returns `placeholder`. Subsequent calls return the last
18247
- * successfully fetched content. If a refresh fails, the previous good
18248
- * value is retained.
18249
- *
18250
- * @example
18251
- * ```typescript
18252
- * const getContent = createAsyncContentCache({
18253
- * fetch: async () => {
18254
- * const res = await fetch('http://127.0.0.1:1936/status');
18255
- * return formatWatcherStatus(await res.json());
18256
- * },
18257
- * placeholder: '> Initializing watcher status...',
18258
- * });
18244
+ * Determines the state of each platform component and generates
18245
+ * HEARTBEAT entries with actionable alert text. Applies the dependency
18246
+ * graph for alert suppression and auto-decline.
18247
+ */
18248
+ /** Derive the full service name from a component name. */
18249
+ function toServiceName(name) {
18250
+ return `jeeves-${name}`;
18251
+ }
18252
+ /** Known dependency declarations for platform components. */
18253
+ const COMPONENT_DEPS = {
18254
+ meta: { hard: ['watcher'], soft: [] },
18255
+ server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
18256
+ runner: { hard: [], soft: [] },
18257
+ watcher: { hard: [], soft: [] },
18258
+ };
18259
+ /** "Not installed" alert text for each platform component. Shared with seedContent. */
18260
+ const NOT_INSTALLED_ALERTS = {
18261
+ runner: 'Not installed. The runner is the job execution engine — it runs scheduled scripts for data fetching, transformation, and maintenance. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-runner-openclaw install`.',
18262
+ watcher: 'Not installed. The watcher indexes documents into a vector store for semantic search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-watcher-openclaw install`.',
18263
+ server: 'Not installed. The server provides a web UI for document browsing, rendering, sharing, and search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-server-openclaw install`.',
18264
+ meta: 'Not installed. Meta is the knowledge synthesis engine. Requires jeeves-watcher. Ask the user for consent to install. On approval, install watcher first if needed, then execute: `npx @karmaniverous/jeeves-meta-openclaw install`.',
18265
+ };
18266
+ /** Alert text generators by state. */
18267
+ const ALERT_TEXT = {
18268
+ runner: {
18269
+ not_installed: NOT_INSTALLED_ALERTS['runner'],
18270
+ config_missing: (configRoot) => `Plugin installed but no configuration file found at \`${configRoot}/jeeves-runner/config.json\`. The plugin installer should have created a default config. Ask the user for consent to re-run the installer: \`npx @karmaniverous/jeeves-runner-openclaw install\`.`,
18271
+ service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-runner service install`. Verify the service is installed.',
18272
+ service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-runner service start`. Verify via `GET http://127.0.0.1:1937/status`.',
18273
+ },
18274
+ watcher: {
18275
+ not_installed: NOT_INSTALLED_ALERTS['watcher'],
18276
+ deps_missing: 'Plugin installed but Qdrant is not responding on `http://127.0.0.1:6333`. Qdrant is the vector database required for semantic search. Ask the user for consent to set up Qdrant. Guide them through installation for their platform — Docker is simplest: `docker run -p 6333:6333 qdrant/qdrant`. Verify via `GET http://127.0.0.1:6333/collections`.',
18277
+ config_missing: (configRoot) => `Plugin installed, Qdrant available, but config file missing or invalid at \`${configRoot}/jeeves-watcher/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-watcher-openclaw install\`.`,
18278
+ service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-watcher service install`. Verify the service is installed.',
18279
+ service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-watcher service start`. Verify via `GET http://127.0.0.1:1936/status`.',
18280
+ },
18281
+ server: {
18282
+ not_installed: NOT_INSTALLED_ALERTS['server'],
18283
+ config_missing: (configRoot) => `Plugin installed but config file missing or invalid at \`${configRoot}/jeeves-server/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-server-openclaw install\`.`,
18284
+ service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-server service install`. Verify the service is installed.',
18285
+ service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-server service start`. Verify via `GET http://127.0.0.1:1934/status`.',
18286
+ },
18287
+ meta: {
18288
+ not_installed: NOT_INSTALLED_ALERTS['meta'],
18289
+ deps_missing: 'Plugin installed but required dependency jeeves-watcher is not available. The watcher must be installed and running before meta can function. Do not attempt to set up meta until jeeves-watcher is healthy.',
18290
+ config_missing: (configRoot) => `Plugin installed, watcher available, but config file missing or invalid at \`${configRoot}/jeeves-meta/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-meta-openclaw install\`.`,
18291
+ service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-meta service install`. Verify the service is installed.',
18292
+ service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-meta service start`. Verify via `GET http://127.0.0.1:1938/status`.',
18293
+ },
18294
+ };
18295
+ /** Default Qdrant URL for watcher dependency check. */
18296
+ const QDRANT_URL = 'http://127.0.0.1:6333';
18297
+ /** Health probe timeout in milliseconds. */
18298
+ const PROBE_TIMEOUT_MS = 3000;
18299
+ /**
18300
+ * Check if Qdrant is reachable (watcher dependency).
18259
18301
  *
18260
- * const writer = createComponentWriter({
18261
- * // ...
18262
- * generateToolsContent: getContent,
18263
- * });
18264
- * ```
18302
+ * @returns True if Qdrant responds.
18265
18303
  */
18304
+ async function isQdrantAvailable() {
18305
+ try {
18306
+ await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS);
18307
+ return true;
18308
+ }
18309
+ catch {
18310
+ return false;
18311
+ }
18312
+ }
18266
18313
  /**
18267
- * Creates a synchronous content accessor backed by an async data source.
18314
+ * Determine the state of a single component.
18268
18315
  *
18269
- * @param options - Cache configuration.
18270
- * @returns A sync `() => string` suitable for `generateToolsContent`.
18316
+ * @param name - Component name.
18317
+ * @param registry - Current component-versions.json contents.
18318
+ * @param configRoot - Config root path.
18319
+ * @param healthySet - Set of component names known to be healthy (for dep checks).
18320
+ * @returns The component's state.
18271
18321
  */
18272
- function createAsyncContentCache(options) {
18273
- const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
18274
- console.warn('[jeeves] async content cache refresh failed:', err);
18275
- }, } = options;
18276
- let cached = placeholder;
18277
- let refreshing = false;
18278
- return () => {
18279
- if (!refreshing) {
18280
- refreshing = true;
18281
- fetchContent()
18282
- .then((content) => {
18283
- cached = content;
18284
- })
18285
- .catch(onError)
18286
- .finally(() => {
18287
- refreshing = false;
18288
- });
18322
+ async function determineComponentState(name, registry, configRoot, healthySet) {
18323
+ // Not in registry = not installed
18324
+ if (!(name in registry))
18325
+ return 'not_installed';
18326
+ // Check hard dependencies
18327
+ const deps = COMPONENT_DEPS[name];
18328
+ for (const hardDep of deps.hard) {
18329
+ if (!healthySet.has(hardDep))
18330
+ return 'deps_missing';
18331
+ }
18332
+ // Watcher-specific: check Qdrant
18333
+ if (name === 'watcher' && !(await isQdrantAvailable())) {
18334
+ return 'deps_missing';
18335
+ }
18336
+ // Check config file
18337
+ const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
18338
+ if (!existsSync(configPath))
18339
+ return 'config_missing';
18340
+ // Fast path: probe HTTP health endpoint
18341
+ try {
18342
+ const url = getServiceUrl(name);
18343
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
18344
+ // Healthy — check for available updates
18345
+ const entry = registry[name];
18346
+ if (entry.pluginPackage && entry.pluginVersion) {
18347
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
18348
+ const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
18349
+ if (latestVersion && semverExports.gt(latestVersion, entry.pluginVersion)) {
18350
+ return 'update_available';
18351
+ }
18289
18352
  }
18290
- return cached;
18291
- };
18353
+ return 'healthy';
18354
+ }
18355
+ catch {
18356
+ // Service not responding — classify sub-state
18357
+ const serviceState = getServiceState(toServiceName(name));
18358
+ if (serviceState === 'not_installed')
18359
+ return 'service_not_installed';
18360
+ if (serviceState === 'stopped')
18361
+ return 'service_stopped';
18362
+ // serviceState === 'running' but HTTP failed — still treat as stopped
18363
+ return 'service_stopped';
18364
+ }
18292
18365
  }
18293
-
18294
18366
  /**
18295
- * Factory function for creating a ComponentWriter from a descriptor.
18367
+ * Generate the alert text for a component in a given state.
18296
18368
  *
18297
- * @remarks
18298
- * Validates the descriptor via Zod schema and creates a ComponentWriter.
18299
- * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
18300
- * `JeevesComponent` interface is no longer accepted.
18369
+ * @param name - Component name.
18370
+ * @param state - The component's state.
18371
+ * @param configRoot - Config root path.
18372
+ * @returns Alert text (list items), or empty string if healthy.
18301
18373
  */
18374
+ function generateAlertText(name, state, configRoot, registry) {
18375
+ if (state === 'healthy')
18376
+ return '';
18377
+ // Update available — dynamic text with version info
18378
+ if (state === 'update_available') {
18379
+ const entry = registry[name];
18380
+ const currentVersion = entry.pluginVersion ?? 'unknown';
18381
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
18382
+ const latestVersion = entry.pluginPackage
18383
+ ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
18384
+ 'unknown')
18385
+ : 'unknown';
18386
+ const installCmd = entry.pluginPackage
18387
+ ? `\`npx ${entry.pluginPackage} install\``
18388
+ : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
18389
+ return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
18390
+ }
18391
+ const componentAlerts = ALERT_TEXT[name];
18392
+ const alertOrFn = componentAlerts[state];
18393
+ if (!alertOrFn)
18394
+ return '';
18395
+ const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
18396
+ return `- ${text}`;
18397
+ }
18302
18398
  /**
18303
- * Create a ComponentWriter for a validated component descriptor.
18304
- *
18305
- * @remarks
18306
- * The descriptor is validated via the Zod schema at runtime.
18307
- * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
18399
+ * Orchestrate HEARTBEAT entries for all platform components.
18308
18400
  *
18309
- * @param descriptor - The component descriptor to validate and wrap.
18310
- * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
18311
- * @returns A new `ComponentWriter` instance.
18312
- * @throws ZodError if the descriptor is invalid.
18401
+ * @param options - Orchestration configuration.
18402
+ * @returns Array of HeartbeatEntry for writeHeartbeatSection.
18313
18403
  */
18314
- function createComponentWriter(descriptor, options) {
18315
- // Validate via Zod throws ZodError with detailed messages on failure
18316
- jeevesComponentDescriptorSchema.parse(descriptor);
18317
- return new ComponentWriter(descriptor, options);
18404
+ async function orchestrateHeartbeat(options) {
18405
+ const { coreConfigDir, configRoot, declinedNames } = options;
18406
+ const registry = readComponentVersions(coreConfigDir);
18407
+ // First pass: determine which components are healthy (for dep resolution)
18408
+ const healthySet = new Set();
18409
+ for (const name of PLATFORM_COMPONENTS) {
18410
+ if (declinedNames.has(toServiceName(name)))
18411
+ continue;
18412
+ if (!(name in registry))
18413
+ continue;
18414
+ try {
18415
+ const url = getServiceUrl(name);
18416
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
18417
+ healthySet.add(name);
18418
+ }
18419
+ catch {
18420
+ // Not healthy — will be classified in second pass
18421
+ }
18422
+ }
18423
+ // Second pass: generate entries
18424
+ const entries = [];
18425
+ for (const name of PLATFORM_COMPONENTS) {
18426
+ const fullName = toServiceName(name);
18427
+ // Declined
18428
+ if (declinedNames.has(fullName)) {
18429
+ // Auto-decline dependents of declined hard deps
18430
+ entries.push({ name: fullName, declined: true, content: '' });
18431
+ continue;
18432
+ }
18433
+ const state = await determineComponentState(name, registry, configRoot, healthySet);
18434
+ // Auto-decline if hard dep is declined
18435
+ const deps = COMPONENT_DEPS[name];
18436
+ const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
18437
+ if (hardDepDeclined) {
18438
+ entries.push({ name: fullName, declined: true, content: '' });
18439
+ continue;
18440
+ }
18441
+ const alertText = generateAlertText(name, state, configRoot, registry);
18442
+ entries.push({ name: fullName, declined: false, content: alertText });
18443
+ }
18444
+ // Add soft-dep informational alerts for any healthy component with soft deps
18445
+ for (const entry of entries) {
18446
+ if (entry.declined || entry.content)
18447
+ continue;
18448
+ // Entry is healthy (no alert, not declined) — check for soft deps
18449
+ const shortName = entry.name.replace(/^jeeves-/, '');
18450
+ const deps = COMPONENT_DEPS[shortName];
18451
+ if (!deps.soft.length)
18452
+ continue;
18453
+ const softAlerts = [];
18454
+ for (const dep of deps.soft) {
18455
+ const depFullName = toServiceName(dep);
18456
+ if (declinedNames.has(depFullName))
18457
+ continue;
18458
+ if (!healthySet.has(dep)) {
18459
+ softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
18460
+ }
18461
+ }
18462
+ if (softAlerts.length > 0) {
18463
+ entry.content = softAlerts.join('\n');
18464
+ }
18465
+ }
18466
+ return entries;
18318
18467
  }
18319
18468
 
18320
18469
  /**
18321
- * Tool result formatters for the OpenClaw plugin SDK.
18470
+ * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
18322
18471
  *
18323
18472
  * @remarks
18324
- * Provides standardised helpers for building `ToolResult` objects:
18325
- * success, error, and connection-error variants.
18326
- */
18327
- /**
18328
- * Format a successful tool result.
18329
- *
18330
- * @param data - Arbitrary data to return as JSON.
18331
- * @returns A `ToolResult` with JSON-stringified content.
18332
- */
18333
- function ok(data) {
18334
- return {
18335
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
18336
- };
18337
- }
18338
- /**
18339
- * Format an error tool result.
18340
- *
18341
- * @param error - Error instance, string, or other value.
18342
- * @returns A `ToolResult` with `isError: true`.
18473
+ * Reads existing HEARTBEAT.md, resolves declined components, runs the
18474
+ * heartbeat state machine, and writes the result. Best-effort: failures
18475
+ * are logged but do not propagate.
18343
18476
  */
18344
- function fail(error) {
18345
- const message = error instanceof Error ? error.message : String(error);
18346
- return {
18347
- content: [{ type: 'text', text: 'Error: ' + message }],
18348
- isError: true,
18349
- };
18350
- }
18351
18477
  /**
18352
- * Format a connection error with actionable guidance.
18353
- *
18354
- * @remarks
18355
- * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
18356
- * `error.cause.code` and returns a user-friendly message referencing
18357
- * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
18358
- * non-connection errors.
18478
+ * Read a file's content, returning empty string if the file does not exist.
18359
18479
  *
18360
- * @param error - Error instance (typically from `fetch`).
18361
- * @param baseUrl - The URL that was being contacted.
18362
- * @param pluginId - The plugin identifier for config guidance.
18363
- * @returns A `ToolResult` with `isError: true`.
18480
+ * @param filePath - Absolute file path.
18481
+ * @returns File content or empty string.
18364
18482
  */
18365
- function connectionFail(error, baseUrl, pluginId) {
18366
- const cause = error instanceof Error ? error.cause : undefined;
18367
- const code = cause && typeof cause === 'object' && 'code' in cause
18368
- ? String(cause.code)
18369
- : '';
18370
- const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
18371
- if (isConnectionError) {
18372
- return {
18373
- content: [
18374
- {
18375
- type: 'text',
18376
- text: [
18377
- `Service not reachable at ${baseUrl}.`,
18378
- 'Either start the service, or if it runs on a different port,',
18379
- `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
18380
- ].join('\n'),
18381
- },
18382
- ],
18383
- isError: true,
18384
- };
18483
+ function readFileOrEmpty(filePath) {
18484
+ try {
18485
+ return readFileSync(filePath, 'utf-8');
18486
+ }
18487
+ catch (err) {
18488
+ if (err instanceof Error &&
18489
+ 'code' in err &&
18490
+ err.code === 'ENOENT') {
18491
+ return '';
18492
+ }
18493
+ throw err;
18385
18494
  }
18386
- return fail(error);
18387
18495
  }
18388
-
18389
- /**
18390
- * Factory for the standard plugin tool set.
18391
- *
18392
- * @remarks
18393
- * Produces four standard tools from a component descriptor:
18394
- * - `{name}_status` - Probe service health + version + uptime
18395
- * - `{name}_config` - Query running config with optional JSONPath
18396
- * - `{name}_config_apply` - Push config patch to running service
18397
- * - `{name}_service` - Service lifecycle management
18398
- *
18399
- * Components add domain-specific tools separately.
18400
- */
18401
- /** Timeout for HTTP probes in milliseconds. */
18402
- const PROBE_TIMEOUT_MS = 5000;
18403
18496
  /**
18404
- * Create the standard plugin tool set from a component descriptor.
18497
+ * Run a single HEARTBEAT orchestration cycle.
18405
18498
  *
18406
- * @param descriptor - The component descriptor.
18407
- * @returns Array of tool descriptors to register.
18499
+ * @param options - Heartbeat cycle configuration.
18408
18500
  */
18409
- function createPluginToolset(descriptor) {
18410
- const { name, defaultPort } = descriptor;
18411
- const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
18412
- const svcManager = createServiceManager(descriptor);
18413
- const statusTool = {
18414
- name: `${name}_status`,
18415
- description: `Get ${name} service health, version, and uptime.`,
18416
- parameters: {
18417
- type: 'object',
18418
- properties: {},
18419
- },
18420
- execute: async () => {
18421
- try {
18422
- const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS);
18423
- if (!res.ok) {
18424
- return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
18425
- }
18426
- const data = await res.json();
18427
- return ok(data);
18428
- }
18429
- catch (err) {
18430
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18431
- }
18432
- },
18433
- };
18434
- const configTool = {
18435
- name: `${name}_config`,
18436
- description: `Query ${name} running configuration. Optional JSONPath filter.`,
18437
- parameters: {
18438
- type: 'object',
18439
- properties: {
18440
- path: {
18441
- type: 'string',
18442
- description: 'JSONPath expression (optional)',
18443
- },
18444
- },
18445
- },
18446
- execute: async (_id, params) => {
18447
- const path = params.path;
18448
- const qs = path ? `?path=${encodeURIComponent(path)}` : '';
18449
- try {
18450
- const result = await fetchJson(`${baseUrl}/config${qs}`);
18451
- return ok(result);
18452
- }
18453
- catch (err) {
18454
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18455
- }
18456
- },
18457
- };
18458
- const configApplyTool = {
18459
- name: `${name}_config_apply`,
18460
- description: `Apply a config patch to the running ${name} service.`,
18461
- parameters: {
18462
- type: 'object',
18463
- properties: {
18464
- config: {
18465
- type: 'object',
18466
- description: 'Config patch to apply',
18467
- },
18468
- },
18469
- required: ['config'],
18470
- },
18471
- execute: async (_id, params) => {
18472
- const config = params.config;
18473
- if (!config) {
18474
- return fail('Missing required parameter: config');
18475
- }
18476
- try {
18477
- const result = await postJson(`${baseUrl}/config/apply`, {
18478
- patch: config,
18479
- });
18480
- return ok(result);
18481
- }
18482
- catch (err) {
18483
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18484
- }
18485
- },
18486
- };
18487
- const serviceTool = {
18488
- name: `${name}_service`,
18489
- description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
18490
- parameters: {
18491
- type: 'object',
18492
- properties: {
18493
- action: {
18494
- type: 'string',
18495
- enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
18496
- description: 'Service action to perform',
18497
- },
18498
- },
18499
- required: ['action'],
18500
- },
18501
- execute: (_id, params) => {
18502
- const action = params.action;
18503
- const validActions = [
18504
- 'install',
18505
- 'uninstall',
18506
- 'start',
18507
- 'stop',
18508
- 'restart',
18509
- 'status',
18510
- ];
18511
- if (!validActions.includes(action)) {
18512
- return Promise.resolve(fail(`Invalid action: ${action}`));
18513
- }
18514
- try {
18515
- if (action === 'status') {
18516
- const state = svcManager.status();
18517
- return Promise.resolve(ok({ service: name, state }));
18518
- }
18519
- // Call the appropriate method
18520
- const methodMap = {
18521
- install: () => {
18522
- svcManager.install();
18523
- },
18524
- uninstall: () => {
18525
- svcManager.uninstall();
18526
- },
18527
- start: () => {
18528
- svcManager.start();
18529
- },
18530
- stop: () => {
18531
- svcManager.stop();
18532
- },
18533
- restart: () => {
18534
- svcManager.restart();
18535
- },
18536
- };
18537
- methodMap[action]();
18538
- return Promise.resolve(ok({ service: name, action, success: true }));
18501
+ async function runHeartbeatCycle(options) {
18502
+ const { workspacePath, coreConfigDir, configRoot } = options;
18503
+ const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
18504
+ try {
18505
+ const existingContent = readFileOrEmpty(heartbeatPath);
18506
+ const parsed = parseHeartbeat(existingContent);
18507
+ const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
18508
+ const entries = await orchestrateHeartbeat({
18509
+ coreConfigDir,
18510
+ configRoot,
18511
+ declinedNames,
18512
+ });
18513
+ // Memory hygiene check (Decision 49)
18514
+ if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
18515
+ const wsConfig = loadWorkspaceConfig(workspacePath);
18516
+ const memoryEntry = checkMemoryHealth({
18517
+ workspacePath,
18518
+ budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
18519
+ warningThreshold: wsConfig?.memory?.warningThreshold ??
18520
+ WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
18521
+ staleDays: wsConfig?.memory?.staleDays ??
18522
+ WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
18523
+ });
18524
+ if (memoryEntry)
18525
+ entries.push(memoryEntry);
18526
+ }
18527
+ else {
18528
+ entries.push({
18529
+ name: MEMORY_HEARTBEAT_NAME,
18530
+ declined: true,
18531
+ content: '',
18532
+ });
18533
+ }
18534
+ // Workspace file size health check (Decision 70)
18535
+ const wsFileResults = checkWorkspaceFileHealth({ workspacePath });
18536
+ const wsFileAlerts = workspaceFileHealthEntries(wsFileResults);
18537
+ for (const alert of wsFileAlerts) {
18538
+ if (declinedNames.has(alert.name)) {
18539
+ entries.push({ name: alert.name, declined: true, content: '' });
18539
18540
  }
18540
- catch (err) {
18541
- const msg = err instanceof Error ? err.message : String(err);
18542
- return Promise.resolve(fail(`Service ${action} failed: ${msg}`));
18541
+ else {
18542
+ entries.push(alert);
18543
18543
  }
18544
- },
18545
- };
18546
- return [statusTool, configTool, configApplyTool, serviceTool];
18544
+ }
18545
+ await writeHeartbeatSection(heartbeatPath, entries);
18546
+ }
18547
+ catch (err) {
18548
+ console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${getErrorMessage(err)}`);
18549
+ }
18547
18550
  }
18548
18551
 
18549
18552
  /**
18550
- * Resolve the version of a package from its `import.meta.url`.
18553
+ * Timer-based orchestrator for managed content writing.
18551
18554
  *
18552
- * @module
18555
+ * @remarks
18556
+ * `ComponentWriter` manages a component's TOOLS.md section writes
18557
+ * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
18558
+ * on a configurable prime-interval timer cycle.
18553
18559
  */
18554
18560
  /**
18555
- * Get the version string from the nearest `package.json` relative to the
18556
- * caller's module URL.
18561
+ * Orchestrates managed content writing for a single Jeeves component.
18557
18562
  *
18558
- * @param importMetaUrl - The `import.meta.url` of the calling module.
18559
- * @returns The `version` field, or `'unknown'` on any error.
18563
+ * @remarks
18564
+ * Created via {@link createComponentWriter}. Manages a timer that fires
18565
+ * at the component's prime-interval, calling `generateToolsContent()`
18566
+ * and `refreshPlatformContent()` on each cycle.
18560
18567
  */
18561
- function getPackageVersion(importMetaUrl) {
18562
- try {
18563
- const dir = fileURLToPath(importMetaUrl);
18564
- const pkgRoot = packageDirectorySync({ cwd: dir });
18565
- if (!pkgRoot)
18566
- return 'unknown';
18567
- const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf-8');
18568
- const pkg = JSON.parse(raw);
18569
- return typeof pkg.version === 'string' ? pkg.version : 'unknown';
18568
+ class ComponentWriter {
18569
+ timer;
18570
+ jitterTimeout;
18571
+ component;
18572
+ configDir;
18573
+ gatewayUrl;
18574
+ pendingCleanups = new Set();
18575
+ /** @internal */
18576
+ constructor(component, options) {
18577
+ this.component = component;
18578
+ this.configDir = getComponentConfigDir(component.name);
18579
+ this.gatewayUrl = options?.gatewayUrl;
18570
18580
  }
18571
- catch {
18572
- return 'unknown';
18581
+ /** The component's config directory path. */
18582
+ get componentConfigDir() {
18583
+ return this.configDir;
18584
+ }
18585
+ /** Whether the writer timer is currently running or pending its first cycle. */
18586
+ get isRunning() {
18587
+ return this.jitterTimeout !== undefined || this.timer !== undefined;
18588
+ }
18589
+ /**
18590
+ * Start the writer timer.
18591
+ *
18592
+ * @remarks
18593
+ * Delays the first cycle by a random jitter (0 to one full interval) to
18594
+ * spread initial writes across all component plugins and reduce EPERM
18595
+ * contention on startup.
18596
+ */
18597
+ start() {
18598
+ if (this.isRunning)
18599
+ return;
18600
+ // Random jitter up to one full interval to spread initial writes
18601
+ const intervalMs = this.component.refreshIntervalSeconds * 1000;
18602
+ const jitterMs = Math.floor(Math.random() * intervalMs);
18603
+ this.jitterTimeout = setTimeout(() => {
18604
+ this.jitterTimeout = undefined;
18605
+ void this.cycle();
18606
+ this.timer = setInterval(() => void this.cycle(), intervalMs);
18607
+ }, jitterMs);
18608
+ }
18609
+ /** Stop the writer timer. */
18610
+ stop() {
18611
+ if (this.jitterTimeout) {
18612
+ clearTimeout(this.jitterTimeout);
18613
+ this.jitterTimeout = undefined;
18614
+ }
18615
+ if (this.timer) {
18616
+ clearInterval(this.timer);
18617
+ this.timer = undefined;
18618
+ }
18619
+ }
18620
+ /**
18621
+ * Execute a single write cycle.
18622
+ *
18623
+ * @remarks
18624
+ * 1. Write the component's TOOLS.md section.
18625
+ * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
18626
+ * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
18627
+ * 4. Run HEARTBEAT health orchestration.
18628
+ */
18629
+ async cycle() {
18630
+ try {
18631
+ const workspacePath = getWorkspacePath();
18632
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
18633
+ // 1. Write the component's TOOLS.md section
18634
+ const toolsContent = this.component.generateToolsContent();
18635
+ await updateManagedSection(toolsPath, toolsContent, {
18636
+ mode: 'section',
18637
+ sectionId: this.component.sectionId,
18638
+ markers: TOOLS_MARKERS,
18639
+ coreVersion: CORE_VERSION,
18640
+ });
18641
+ // 2. Platform content maintenance
18642
+ await refreshPlatformContent({
18643
+ coreVersion: CORE_VERSION,
18644
+ componentName: this.component.name,
18645
+ componentVersion: this.component.version,
18646
+ servicePackage: this.component.servicePackage,
18647
+ pluginPackage: this.component.pluginPackage,
18648
+ });
18649
+ // 3. Cleanup escalation
18650
+ if (this.gatewayUrl) {
18651
+ scanAndEscalateCleanup([
18652
+ { filePath: toolsPath, markerIdentity: 'TOOLS' },
18653
+ {
18654
+ filePath: join(workspacePath, WORKSPACE_FILES.soul),
18655
+ markerIdentity: 'SOUL',
18656
+ },
18657
+ {
18658
+ filePath: join(workspacePath, WORKSPACE_FILES.agents),
18659
+ markerIdentity: 'AGENTS',
18660
+ },
18661
+ ], this.gatewayUrl, this.pendingCleanups);
18662
+ }
18663
+ // 4. HEARTBEAT orchestration
18664
+ await runHeartbeatCycle({
18665
+ workspacePath,
18666
+ coreConfigDir: getCoreConfigDir(),
18667
+ configRoot: getConfigRoot$1(),
18668
+ });
18669
+ }
18670
+ catch (err) {
18671
+ console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${getErrorMessage(err)}`);
18672
+ }
18573
18673
  }
18574
18674
  }
18575
18675
 
18576
18676
  /**
18577
- * Plugin resolution helpers for the OpenClaw plugin SDK.
18677
+ * Creates a synchronous content accessor backed by an async data source.
18578
18678
  *
18579
18679
  * @remarks
18580
- * Provides workspace path resolution and plugin setting resolution
18581
- * with a standard three-step fallback chain:
18582
- * plugin config environment variable default value.
18680
+ * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
18681
+ * the interface is synchronous, but most components fetch live data from
18682
+ * their HTTP service. This utility returns a sync `() => string` that
18683
+ * serves the last successfully fetched value while kicking off a background
18684
+ * refresh on each call.
18685
+ *
18686
+ * First call returns `placeholder`. Subsequent calls return the last
18687
+ * successfully fetched content. If a refresh fails, the previous good
18688
+ * value is retained.
18689
+ *
18690
+ * @example
18691
+ * ```typescript
18692
+ * const getContent = createAsyncContentCache({
18693
+ * fetch: async () => {
18694
+ * const res = await fetch('http://127.0.0.1:1936/status');
18695
+ * return formatWatcherStatus(await res.json());
18696
+ * },
18697
+ * placeholder: '> Initializing watcher status...',
18698
+ * });
18699
+ *
18700
+ * const writer = createComponentWriter({
18701
+ * // ...
18702
+ * generateToolsContent: getContent,
18703
+ * });
18704
+ * ```
18583
18705
  */
18584
18706
  /**
18585
- * Resolve the workspace root from the OpenClaw plugin API.
18586
- *
18587
- * @remarks
18588
- * Tries three sources in order:
18589
- * 1. `api.config.agents.defaults.workspace` — explicit config
18590
- * 2. `api.resolvePath('.')` — gateway-provided path resolver
18591
- * 3. `process.cwd()` — last resort
18707
+ * Creates a synchronous content accessor backed by an async data source.
18592
18708
  *
18593
- * @param api - The plugin API object provided by the gateway.
18594
- * @returns Absolute path to the workspace root.
18709
+ * @param options - Cache configuration.
18710
+ * @returns A sync `() => string` suitable for `generateToolsContent`.
18595
18711
  */
18596
- function resolveWorkspacePath(api) {
18597
- const configured = api.config?.agents?.defaults?.workspace;
18598
- if (typeof configured === 'string' && configured.trim()) {
18599
- return configured;
18600
- }
18601
- if (typeof api.resolvePath === 'function') {
18602
- return api.resolvePath('.');
18603
- }
18604
- return process.cwd();
18712
+ function createAsyncContentCache(options) {
18713
+ const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
18714
+ console.warn('[jeeves] async content cache refresh failed:', err);
18715
+ }, } = options;
18716
+ let cached = placeholder;
18717
+ let refreshing = false;
18718
+ return () => {
18719
+ if (!refreshing) {
18720
+ refreshing = true;
18721
+ fetchContent()
18722
+ .then((content) => {
18723
+ cached = content;
18724
+ })
18725
+ .catch(onError)
18726
+ .finally(() => {
18727
+ refreshing = false;
18728
+ });
18729
+ }
18730
+ return cached;
18731
+ };
18605
18732
  }
18733
+
18606
18734
  /**
18607
- * Resolve a plugin setting via the standard three-step fallback chain:
18608
- * plugin config → environment variable → fallback value.
18735
+ * Factory function for creating a ComponentWriter from a descriptor.
18609
18736
  *
18610
- * @param api - Plugin API object.
18611
- * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
18612
- * @param key - Config key within the plugin's config object.
18613
- * @param envVar - Environment variable name.
18614
- * @param fallback - Default value if neither source provides one.
18615
- * @returns The resolved setting value.
18737
+ * @remarks
18738
+ * Validates the descriptor via Zod schema and creates a ComponentWriter.
18739
+ * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
18740
+ * `JeevesComponent` interface is no longer accepted.
18616
18741
  */
18617
- function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
18618
- const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
18619
- if (typeof fromPlugin === 'string')
18620
- return fromPlugin;
18621
- const fromEnv = process.env[envVar];
18622
- if (fromEnv)
18623
- return fromEnv;
18624
- return fallback;
18742
+ /**
18743
+ * Create a ComponentWriter for a validated component descriptor.
18744
+ *
18745
+ * @remarks
18746
+ * The descriptor is validated via the Zod schema at runtime.
18747
+ * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
18748
+ *
18749
+ * @param descriptor - The component descriptor to validate and wrap.
18750
+ * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
18751
+ * @returns A new `ComponentWriter` instance.
18752
+ * @throws ZodError if the descriptor is invalid.
18753
+ */
18754
+ function createComponentWriter(descriptor, options) {
18755
+ // Validate via Zod — throws ZodError with detailed messages on failure
18756
+ jeevesComponentDescriptorSchema.parse(descriptor);
18757
+ return new ComponentWriter(descriptor, options);
18625
18758
  }
18626
18759
 
18627
18760
  /**