@karmaniverous/jeeves-server-openclaw 0.7.1 → 0.7.3

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
@@ -14,8 +14,8 @@ import 'vm';
14
14
  import require$$0$5 from 'node:events';
15
15
  import require$$1, { execSync } from 'node:child_process';
16
16
  import process$2 from 'node:process';
17
- import 'node:fs/promises';
18
17
  import { fileURLToPath } from 'node:url';
18
+ import 'node:fs/promises';
19
19
 
20
20
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
21
21
 
@@ -15490,14 +15490,14 @@ const PLATFORM_COMPONENTS = [
15490
15490
  * Core library version, inlined at build time.
15491
15491
  *
15492
15492
  * @remarks
15493
- * The `0.5.2` placeholder is replaced by
15493
+ * The `0.5.4` placeholder is replaced by
15494
15494
  * `@rollup/plugin-replace` during the build with the actual version
15495
15495
  * from `package.json`. This ensures the correct version survives
15496
15496
  * when consumers bundle core into their own dist (where runtime
15497
15497
  * `import.meta.url`-based resolution would find the wrong package.json).
15498
15498
  */
15499
15499
  /** The core library version from package.json (inlined at build time). */
15500
- const CORE_VERSION = '0.5.2';
15500
+ const CORE_VERSION = '0.5.4';
15501
15501
 
15502
15502
  /**
15503
15503
  * Workspace and config root initialization.
@@ -16344,63 +16344,6 @@ function getEffectiveServiceName(descriptor) {
16344
16344
  return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
16345
16345
  }
16346
16346
 
16347
- /**
16348
- * HTTP helpers for the OpenClaw plugin SDK.
16349
- *
16350
- * @remarks
16351
- * Thin wrappers around `fetch` that throw on non-OK responses
16352
- * and handle JSON serialisation/deserialisation.
16353
- */
16354
- /**
16355
- * Fetch a URL with an automatic abort timeout.
16356
- *
16357
- * @param url - URL to fetch.
16358
- * @param timeoutMs - Timeout in milliseconds before aborting.
16359
- * @param init - Optional `fetch` init options.
16360
- * @returns The fetch Response object.
16361
- */
16362
- async function fetchWithTimeout(url, timeoutMs, init) {
16363
- const controller = new AbortController();
16364
- const timeout = setTimeout(() => {
16365
- controller.abort();
16366
- }, timeoutMs);
16367
- try {
16368
- return await fetch(url, { ...init, signal: controller.signal });
16369
- }
16370
- finally {
16371
- clearTimeout(timeout);
16372
- }
16373
- }
16374
- /**
16375
- * Fetch JSON from a URL, throwing on non-OK responses.
16376
- *
16377
- * @param url - URL to fetch.
16378
- * @param init - Optional `fetch` init options.
16379
- * @returns Parsed JSON response body.
16380
- * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
16381
- */
16382
- async function fetchJson(url, init) {
16383
- const res = await fetch(url, init);
16384
- if (!res.ok) {
16385
- throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
16386
- }
16387
- return res.json();
16388
- }
16389
- /**
16390
- * POST JSON to a URL and return parsed response.
16391
- *
16392
- * @param url - URL to POST to.
16393
- * @param body - Request body (will be JSON-stringified).
16394
- * @returns Parsed JSON response body.
16395
- */
16396
- async function postJson(url, body) {
16397
- return fetchJson(url, {
16398
- method: 'POST',
16399
- headers: { 'Content-Type': 'application/json' },
16400
- body: JSON.stringify(body),
16401
- });
16402
- }
16403
-
16404
16347
  /**
16405
16348
  * Platform-aware service state detection.
16406
16349
  *
@@ -16777,247 +16720,603 @@ function createServiceManager(descriptor) {
16777
16720
  }
16778
16721
 
16779
16722
  /**
16780
- * Similarity-based cleanup detection for orphaned managed content.
16723
+ * HTTP helpers for the OpenClaw plugin SDK.
16781
16724
  *
16782
16725
  * @remarks
16783
- * Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
16784
- * when orphaned managed content exists in the user content zone.
16726
+ * Thin wrappers around `fetch` that throw on non-OK responses
16727
+ * and handle JSON serialisation/deserialisation.
16785
16728
  */
16786
- /** Default similarity threshold for cleanup detection. */
16787
- const DEFAULT_THRESHOLD = 0.15;
16788
16729
  /**
16789
- * Generate a set of n-word shingles from text.
16730
+ * Fetch a URL with an automatic abort timeout.
16790
16731
  *
16791
- * @param text - Input text.
16792
- * @param n - Shingle size (default 3).
16793
- * @returns Set of n-word shingles.
16732
+ * @param url - URL to fetch.
16733
+ * @param timeoutMs - Timeout in milliseconds before aborting.
16734
+ * @param init - Optional `fetch` init options.
16735
+ * @returns The fetch Response object.
16794
16736
  */
16795
- function shingles(text, n = 3) {
16796
- const words = text.toLowerCase().split(/\s+/).filter(Boolean);
16797
- const set = new Set();
16798
- for (let i = 0; i <= words.length - n; i++) {
16799
- set.add(words.slice(i, i + n).join(' '));
16737
+ async function fetchWithTimeout(url, timeoutMs, init) {
16738
+ const controller = new AbortController();
16739
+ const timeout = setTimeout(() => {
16740
+ controller.abort();
16741
+ }, timeoutMs);
16742
+ try {
16743
+ return await fetch(url, { ...init, signal: controller.signal });
16744
+ }
16745
+ finally {
16746
+ clearTimeout(timeout);
16800
16747
  }
16801
- return set;
16802
16748
  }
16803
16749
  /**
16804
- * Compute Jaccard similarity between two sets.
16750
+ * Fetch JSON from a URL, throwing on non-OK responses.
16805
16751
  *
16806
- * @param a - First set.
16807
- * @param b - Second set.
16808
- * @returns Jaccard similarity coefficient (0 to 1).
16752
+ * @param url - URL to fetch.
16753
+ * @param init - Optional `fetch` init options.
16754
+ * @returns Parsed JSON response body.
16755
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
16809
16756
  */
16810
- function jaccard(a, b) {
16811
- if (a.size === 0 && b.size === 0)
16812
- return 0;
16813
- let intersection = 0;
16814
- for (const item of a) {
16815
- if (b.has(item))
16816
- intersection++;
16757
+ async function fetchJson(url, init) {
16758
+ const res = await fetch(url, init);
16759
+ if (!res.ok) {
16760
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
16817
16761
  }
16818
- return intersection / (a.size + b.size - intersection);
16762
+ return res.json();
16819
16763
  }
16820
16764
  /**
16821
- * Check whether user content contains orphaned managed content.
16765
+ * POST JSON to a URL and return parsed response.
16822
16766
  *
16823
- * @param managedContent - The current managed block content.
16824
- * @param userContent - Content below the END marker.
16825
- * @param threshold - Jaccard threshold (default 0.15).
16826
- * @returns `true` if cleanup is needed.
16767
+ * @param url - URL to POST to.
16768
+ * @param body - Request body (will be JSON-stringified).
16769
+ * @returns Parsed JSON response body.
16827
16770
  */
16828
- function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD) {
16829
- if (!userContent.trim())
16830
- return false;
16831
- return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
16771
+ async function postJson(url, body) {
16772
+ return fetchJson(url, {
16773
+ method: 'POST',
16774
+ headers: { 'Content-Type': 'application/json' },
16775
+ body: JSON.stringify(body),
16776
+ });
16832
16777
  }
16833
16778
 
16834
16779
  /**
16835
- * Strip foreign managed blocks from content.
16780
+ * Tool result formatters for the OpenClaw plugin SDK.
16836
16781
  *
16837
16782
  * @remarks
16838
- * Prevents cross-contamination by removing managed blocks that belong
16839
- * to other marker sets. For example, when writing TOOLS.md with TOOLS
16840
- * markers, any SOUL or AGENTS managed blocks found in the user content
16841
- * zone are stripped — they don't belong there.
16783
+ * Provides standardised helpers for building `ToolResult` objects:
16784
+ * success, error, and connection-error variants.
16785
+ */
16786
+ /**
16787
+ * Format a successful tool result.
16842
16788
  *
16843
- * @packageDocumentation
16789
+ * @param data - Arbitrary data to return as JSON.
16790
+ * @returns A `ToolResult` with JSON-stringified content.
16844
16791
  */
16792
+ function ok(data) {
16793
+ return {
16794
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
16795
+ };
16796
+ }
16845
16797
  /**
16846
- * Build a regex that matches an entire managed block (BEGIN marker through END marker).
16798
+ * Format an error tool result.
16847
16799
  *
16848
- * @param markers - The marker set to match.
16849
- * @returns A regex that matches the full block including markers.
16800
+ * @param error - Error instance, string, or other value.
16801
+ * @returns A `ToolResult` with `isError: true`.
16850
16802
  */
16851
- function buildBlockPattern(markers) {
16852
- return new RegExp(`\\s*<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->[\\s\\S]*?<!--\\s*${escapeForRegex(markers.end)}\\s*-->\\s*`, 'g');
16803
+ function fail(error) {
16804
+ const message = error instanceof Error ? error.message : String(error);
16805
+ return {
16806
+ content: [{ type: 'text', text: 'Error: ' + message }],
16807
+ isError: true,
16808
+ };
16853
16809
  }
16854
16810
  /**
16855
- * Strip managed blocks belonging to foreign marker sets from content.
16811
+ * Format a connection error with actionable guidance.
16856
16812
  *
16857
- * @param content - The content to clean (typically user content zone).
16858
- * @param currentMarkers - The marker set that owns this file (will NOT be stripped).
16859
- * @returns Content with foreign managed blocks removed.
16813
+ * @remarks
16814
+ * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
16815
+ * `error.cause.code` and returns a user-friendly message referencing
16816
+ * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
16817
+ * non-connection errors.
16818
+ *
16819
+ * @param error - Error instance (typically from `fetch`).
16820
+ * @param baseUrl - The URL that was being contacted.
16821
+ * @param pluginId - The plugin identifier for config guidance.
16822
+ * @returns A `ToolResult` with `isError: true`.
16860
16823
  */
16861
- function stripForeignMarkers(content, currentMarkers) {
16862
- let result = content;
16863
- for (const markers of ALL_MARKERS) {
16864
- // Skip the current file's own markers
16865
- if (markers.begin === currentMarkers.begin)
16866
- continue;
16867
- const pattern = buildBlockPattern(markers);
16868
- result = result.replace(pattern, '\n');
16824
+ function connectionFail(error, baseUrl, pluginId) {
16825
+ const cause = error instanceof Error ? error.cause : undefined;
16826
+ const code = cause && typeof cause === 'object' && 'code' in cause
16827
+ ? String(cause.code)
16828
+ : '';
16829
+ const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
16830
+ if (isConnectionError) {
16831
+ return {
16832
+ content: [
16833
+ {
16834
+ type: 'text',
16835
+ text: [
16836
+ `Service not reachable at ${baseUrl}.`,
16837
+ 'Either start the service, or if it runs on a different port,',
16838
+ `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
16839
+ ].join('\n'),
16840
+ },
16841
+ ],
16842
+ isError: true,
16843
+ };
16869
16844
  }
16870
- // Clean up multiple blank lines left by removals
16871
- return result.replace(/\n{3,}/g, '\n\n').trim();
16845
+ return fail(error);
16872
16846
  }
16873
16847
 
16874
16848
  /**
16875
- * Generic managed-section writer with block and section modes.
16849
+ * Factory for the standard plugin tool set.
16876
16850
  *
16877
16851
  * @remarks
16878
- * Supports two modes:
16879
- * - `block`: Replaces the entire managed block (SOUL.md, AGENTS.md).
16880
- * - `section`: Upserts a named H2 section within the managed block (TOOLS.md).
16852
+ * Produces four standard tools from a component descriptor:
16853
+ * - `{name}_status` - Probe service health + version + uptime
16854
+ * - `{name}_config` - Query running config with optional JSONPath
16855
+ * - `{name}_config_apply` - Push config patch to running service
16856
+ * - `{name}_service` - Service lifecycle management
16881
16857
  *
16882
- * Provides file-level locking, version-stamp convergence, and atomic writes.
16858
+ * Components add domain-specific tools separately.
16883
16859
  */
16860
+ /** Timeout for HTTP probes in milliseconds. */
16861
+ const PROBE_TIMEOUT_MS$1 = 5000;
16884
16862
  /**
16885
- * Update a managed section in a file.
16863
+ * Create the standard plugin tool set from a component descriptor.
16886
16864
  *
16887
- * @param filePath - Absolute path to the target file.
16888
- * @param content - New content to write.
16889
- * @param options - Write mode and optional configuration.
16865
+ * @param descriptor - The component descriptor.
16866
+ * @returns Array of tool descriptors to register.
16890
16867
  */
16891
- async function updateManagedSection(filePath, content, options = {}) {
16892
- const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
16893
- if (mode === 'section' && !sectionId) {
16894
- throw new Error('sectionId is required when mode is "section"');
16895
- }
16896
- const dir = dirname(filePath);
16897
- if (!existsSync(dir)) {
16898
- mkdirSync(dir, { recursive: true });
16899
- }
16900
- // Create file if it doesn't exist
16901
- if (!existsSync(filePath)) {
16902
- writeFileSync(filePath, '', 'utf-8');
16903
- }
16904
- try {
16905
- await withFileLock(filePath, () => {
16906
- const fileContent = readFileSync(filePath, 'utf-8');
16907
- const parsed = parseManaged(fileContent, markers);
16908
- // Version-stamp convergence check (block mode only).
16909
- // In section mode, components always write their own sections — the version
16910
- // stamp governs shared content convergence, not component-specific sections.
16911
- if (mode === 'block' &&
16912
- !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
16913
- return;
16914
- }
16915
- let newManagedBody;
16916
- if (mode === 'block') {
16917
- // Prepend H1 title if markers specify one
16918
- newManagedBody = markers.title
16919
- ? `# ${markers.title}\n\n${content}`
16920
- : content;
16921
- }
16922
- else {
16923
- // Section mode: upsert the named section
16924
- const sections = [...parsed.sections];
16925
- const existingIdx = sections.findIndex((s) => s.id === sectionId);
16926
- if (existingIdx >= 0) {
16927
- sections[existingIdx] = { id: sectionId, content };
16928
- }
16929
- else {
16930
- sections.push({ id: sectionId, content });
16868
+ function createPluginToolset(descriptor) {
16869
+ const { name, defaultPort } = descriptor;
16870
+ const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
16871
+ const svcManager = createServiceManager(descriptor);
16872
+ const statusTool = {
16873
+ name: `${name}_status`,
16874
+ description: `Get ${name} service health, version, and uptime.`,
16875
+ parameters: {
16876
+ type: 'object',
16877
+ properties: {},
16878
+ },
16879
+ execute: async () => {
16880
+ try {
16881
+ const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS$1);
16882
+ if (!res.ok) {
16883
+ return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
16931
16884
  }
16932
- sortSectionsByOrder(sections);
16933
- const sectionText = sections
16934
- .map((s) => `## ${s.id}\n\n${s.content}`)
16935
- .join('\n\n');
16936
- // Prepend H1 title if markers specify one
16937
- newManagedBody = markers.title
16938
- ? `# ${markers.title}\n\n${sectionText}`
16939
- : sectionText;
16885
+ const data = await res.json();
16886
+ return ok(data);
16940
16887
  }
16941
- // Build the full managed block
16942
- const beginLine = formatBeginMarker(markers.begin, coreVersion);
16943
- const endLine = formatEndMarker(markers.end);
16944
- // Combine all user content for cleanup detection
16945
- const rawUserContent = [parsed.beforeContent, parsed.userContent]
16946
- .filter(Boolean)
16947
- .join('\n\n')
16948
- .trim();
16949
- // Strip foreign managed blocks from user content (cross-contamination fix)
16950
- const userContent = stripForeignMarkers(rawUserContent, markers);
16951
- const cleanupNeeded = needsCleanup(newManagedBody, userContent);
16952
- const managedParts = [];
16953
- managedParts.push(beginLine);
16954
- if (cleanupNeeded) {
16955
- managedParts.push('');
16956
- managedParts.push(CLEANUP_FLAG);
16888
+ catch (err) {
16889
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16957
16890
  }
16958
- managedParts.push('');
16959
- managedParts.push(newManagedBody);
16960
- managedParts.push('');
16961
- managedParts.push(endLine);
16962
- const managedBlock = managedParts.join('\n');
16963
- let newFileContent;
16964
- if (parsed.found) {
16965
- // Existing block: update in place — preserve position, don't move.
16966
- // Strip foreign managed blocks from both content zones (cross-contamination fix).
16967
- const cleanBefore = stripForeignMarkers(parsed.beforeContent, markers);
16968
- const cleanAfter = stripForeignMarkers(parsed.userContent, markers);
16969
- const fileParts = [];
16970
- if (cleanBefore) {
16971
- fileParts.push(cleanBefore);
16972
- fileParts.push('');
16973
- }
16974
- fileParts.push(managedBlock);
16975
- if (cleanAfter) {
16976
- fileParts.push('');
16977
- fileParts.push(cleanAfter);
16978
- }
16979
- fileParts.push('');
16980
- newFileContent = fileParts.join('\n');
16891
+ },
16892
+ };
16893
+ const configTool = {
16894
+ name: `${name}_config`,
16895
+ description: `Query ${name} running configuration. Optional JSONPath filter.`,
16896
+ parameters: {
16897
+ type: 'object',
16898
+ properties: {
16899
+ path: {
16900
+ type: 'string',
16901
+ description: 'JSONPath expression (optional)',
16902
+ },
16903
+ },
16904
+ },
16905
+ execute: async (_id, params) => {
16906
+ const path = params.path;
16907
+ const qs = path ? `?path=${encodeURIComponent(path)}` : '';
16908
+ try {
16909
+ const result = await fetchJson(`${baseUrl}/config${qs}`);
16910
+ return ok(result);
16981
16911
  }
16982
- else {
16983
- // No existing block: insert new block using the configured position.
16984
- // Strip orphaned same-type BEGIN markers from user content to prevent
16985
- // the parser from pairing them with the new END marker on the next cycle.
16986
- const orphanedBeginRe = new RegExp(`^<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$(?:\\r?\\n)?`, 'gm');
16987
- const cleanUserContent = userContent
16988
- .replace(orphanedBeginRe, '')
16989
- .replace(/\n{3,}/g, '\n\n')
16990
- .trim();
16991
- const position = markers.position ?? 'top';
16992
- const fileParts = [];
16993
- if (position === 'bottom') {
16994
- if (cleanUserContent) {
16995
- fileParts.push(cleanUserContent);
16996
- fileParts.push('');
16997
- }
16998
- fileParts.push(managedBlock);
16999
- }
17000
- else {
17001
- fileParts.push(managedBlock);
17002
- if (cleanUserContent) {
17003
- fileParts.push('');
17004
- fileParts.push(cleanUserContent);
17005
- }
16912
+ catch (err) {
16913
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16914
+ }
16915
+ },
16916
+ };
16917
+ const configApplyTool = {
16918
+ name: `${name}_config_apply`,
16919
+ description: `Apply a config patch to the running ${name} service.`,
16920
+ parameters: {
16921
+ type: 'object',
16922
+ properties: {
16923
+ config: {
16924
+ type: 'object',
16925
+ description: 'Config patch to apply',
16926
+ },
16927
+ },
16928
+ required: ['config'],
16929
+ },
16930
+ execute: async (_id, params) => {
16931
+ const config = params.config;
16932
+ if (!config) {
16933
+ return fail('Missing required parameter: config');
16934
+ }
16935
+ try {
16936
+ const result = await postJson(`${baseUrl}/config/apply`, {
16937
+ patch: config,
16938
+ });
16939
+ return ok(result);
16940
+ }
16941
+ catch (err) {
16942
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
16943
+ }
16944
+ },
16945
+ };
16946
+ const serviceTool = {
16947
+ name: `${name}_service`,
16948
+ description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
16949
+ parameters: {
16950
+ type: 'object',
16951
+ properties: {
16952
+ action: {
16953
+ type: 'string',
16954
+ enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
16955
+ description: 'Service action to perform',
16956
+ },
16957
+ },
16958
+ required: ['action'],
16959
+ },
16960
+ execute: (_id, params) => {
16961
+ const action = params.action;
16962
+ const validActions = [
16963
+ 'install',
16964
+ 'uninstall',
16965
+ 'start',
16966
+ 'stop',
16967
+ 'restart',
16968
+ 'status',
16969
+ ];
16970
+ if (!validActions.includes(action)) {
16971
+ return Promise.resolve(fail(`Invalid action: ${action}`));
16972
+ }
16973
+ try {
16974
+ if (action === 'status') {
16975
+ const state = svcManager.status();
16976
+ return Promise.resolve(ok({ service: name, state }));
17006
16977
  }
17007
- fileParts.push('');
17008
- newFileContent = fileParts.join('\n');
16978
+ // Call the appropriate method
16979
+ const methodMap = {
16980
+ install: () => {
16981
+ svcManager.install();
16982
+ },
16983
+ uninstall: () => {
16984
+ svcManager.uninstall();
16985
+ },
16986
+ start: () => {
16987
+ svcManager.start();
16988
+ },
16989
+ stop: () => {
16990
+ svcManager.stop();
16991
+ },
16992
+ restart: () => {
16993
+ svcManager.restart();
16994
+ },
16995
+ };
16996
+ methodMap[action]();
16997
+ return Promise.resolve(ok({ service: name, action, success: true }));
17009
16998
  }
17010
- atomicWrite(filePath, newFileContent);
17011
- });
16999
+ catch (err) {
17000
+ return Promise.resolve(fail(`Service ${action} failed: ${getErrorMessage(err)}`));
17001
+ }
17002
+ },
17003
+ };
17004
+ return [statusTool, configTool, configApplyTool, serviceTool];
17005
+ }
17006
+
17007
+ /**
17008
+ * Plugin resolution helpers for the OpenClaw plugin SDK.
17009
+ *
17010
+ * @remarks
17011
+ * Provides workspace path resolution and plugin setting resolution
17012
+ * with a standard three-step fallback chain:
17013
+ * plugin config → environment variable → default value.
17014
+ */
17015
+ /**
17016
+ * Resolve the workspace root from the OpenClaw plugin API.
17017
+ *
17018
+ * @remarks
17019
+ * Tries three sources in order:
17020
+ * 1. `api.config.agents.defaults.workspace` — explicit config
17021
+ * 2. `api.resolvePath('.')` — gateway-provided path resolver
17022
+ * 3. `process.cwd()` — last resort
17023
+ *
17024
+ * @param api - The plugin API object provided by the gateway.
17025
+ * @returns Absolute path to the workspace root.
17026
+ */
17027
+ function resolveWorkspacePath(api) {
17028
+ const configured = api.config?.agents?.defaults?.workspace;
17029
+ if (typeof configured === 'string' && configured.trim()) {
17030
+ return configured;
17012
17031
  }
17013
- catch (err) {
17014
- // Log warning but don't throw — writer cycles are periodic
17015
- console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${getErrorMessage(err)}`);
17032
+ if (typeof api.resolvePath === 'function') {
17033
+ return api.resolvePath('.');
17016
17034
  }
17035
+ return process.cwd();
17017
17036
  }
17018
-
17019
- var agentsSectionContent = `## "I'll Note This" Is Not Noting
17020
-
17037
+ /**
17038
+ * Resolve a plugin setting via the standard three-step fallback chain:
17039
+ * plugin config → environment variable → fallback value.
17040
+ *
17041
+ * @param api - Plugin API object.
17042
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
17043
+ * @param key - Config key within the plugin's config object.
17044
+ * @param envVar - Environment variable name.
17045
+ * @param fallback - Default value if neither source provides one.
17046
+ * @returns The resolved setting value.
17047
+ */
17048
+ function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
17049
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
17050
+ if (typeof fromPlugin === 'string')
17051
+ return fromPlugin;
17052
+ const fromEnv = process.env[envVar];
17053
+ if (fromEnv)
17054
+ return fromEnv;
17055
+ return fallback;
17056
+ }
17057
+ /**
17058
+ * Resolve an optional plugin setting via the two-step fallback chain:
17059
+ * plugin config → environment variable. Returns `undefined` if neither
17060
+ * source provides a value.
17061
+ *
17062
+ * @param api - Plugin API object.
17063
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
17064
+ * @param key - Config key within the plugin's config object.
17065
+ * @param envVar - Environment variable name.
17066
+ * @returns The resolved setting value, or `undefined`.
17067
+ */
17068
+ function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
17069
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
17070
+ if (typeof fromPlugin === 'string')
17071
+ return fromPlugin;
17072
+ const fromEnv = process.env[envVar];
17073
+ if (fromEnv)
17074
+ return fromEnv;
17075
+ return undefined;
17076
+ }
17077
+
17078
+ /**
17079
+ * Similarity-based cleanup detection for orphaned managed content.
17080
+ *
17081
+ * @remarks
17082
+ * Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
17083
+ * when orphaned managed content exists in the user content zone.
17084
+ */
17085
+ /** Default similarity threshold for cleanup detection. */
17086
+ const DEFAULT_THRESHOLD = 0.15;
17087
+ /**
17088
+ * Generate a set of n-word shingles from text.
17089
+ *
17090
+ * @param text - Input text.
17091
+ * @param n - Shingle size (default 3).
17092
+ * @returns Set of n-word shingles.
17093
+ */
17094
+ function shingles(text, n = 3) {
17095
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
17096
+ const set = new Set();
17097
+ for (let i = 0; i <= words.length - n; i++) {
17098
+ set.add(words.slice(i, i + n).join(' '));
17099
+ }
17100
+ return set;
17101
+ }
17102
+ /**
17103
+ * Compute Jaccard similarity between two sets.
17104
+ *
17105
+ * @param a - First set.
17106
+ * @param b - Second set.
17107
+ * @returns Jaccard similarity coefficient (0 to 1).
17108
+ */
17109
+ function jaccard(a, b) {
17110
+ if (a.size === 0 && b.size === 0)
17111
+ return 0;
17112
+ let intersection = 0;
17113
+ for (const item of a) {
17114
+ if (b.has(item))
17115
+ intersection++;
17116
+ }
17117
+ return intersection / (a.size + b.size - intersection);
17118
+ }
17119
+ /**
17120
+ * Check whether user content contains orphaned managed content.
17121
+ *
17122
+ * @param managedContent - The current managed block content.
17123
+ * @param userContent - Content below the END marker.
17124
+ * @param threshold - Jaccard threshold (default 0.15).
17125
+ * @returns `true` if cleanup is needed.
17126
+ */
17127
+ function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD) {
17128
+ if (!userContent.trim())
17129
+ return false;
17130
+ return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
17131
+ }
17132
+
17133
+ /**
17134
+ * Strip foreign managed blocks from content.
17135
+ *
17136
+ * @remarks
17137
+ * Prevents cross-contamination by removing managed blocks that belong
17138
+ * to other marker sets. For example, when writing TOOLS.md with TOOLS
17139
+ * markers, any SOUL or AGENTS managed blocks found in the user content
17140
+ * zone are stripped — they don't belong there.
17141
+ *
17142
+ * @packageDocumentation
17143
+ */
17144
+ /**
17145
+ * Build a regex that matches an entire managed block (BEGIN marker through END marker).
17146
+ *
17147
+ * @param markers - The marker set to match.
17148
+ * @returns A regex that matches the full block including markers.
17149
+ */
17150
+ function buildBlockPattern(markers) {
17151
+ return new RegExp(`\\s*<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->[\\s\\S]*?<!--\\s*${escapeForRegex(markers.end)}\\s*-->\\s*`, 'g');
17152
+ }
17153
+ /**
17154
+ * Strip managed blocks belonging to foreign marker sets from content.
17155
+ *
17156
+ * @param content - The content to clean (typically user content zone).
17157
+ * @param currentMarkers - The marker set that owns this file (will NOT be stripped).
17158
+ * @returns Content with foreign managed blocks removed.
17159
+ */
17160
+ function stripForeignMarkers(content, currentMarkers) {
17161
+ let result = content;
17162
+ for (const markers of ALL_MARKERS) {
17163
+ // Skip the current file's own markers
17164
+ if (markers.begin === currentMarkers.begin)
17165
+ continue;
17166
+ const pattern = buildBlockPattern(markers);
17167
+ result = result.replace(pattern, '\n');
17168
+ }
17169
+ // Clean up multiple blank lines left by removals
17170
+ return result.replace(/\n{3,}/g, '\n\n').trim();
17171
+ }
17172
+
17173
+ /**
17174
+ * Generic managed-section writer with block and section modes.
17175
+ *
17176
+ * @remarks
17177
+ * Supports two modes:
17178
+ * - `block`: Replaces the entire managed block (SOUL.md, AGENTS.md).
17179
+ * - `section`: Upserts a named H2 section within the managed block (TOOLS.md).
17180
+ *
17181
+ * Provides file-level locking, version-stamp convergence, and atomic writes.
17182
+ */
17183
+ /**
17184
+ * Update a managed section in a file.
17185
+ *
17186
+ * @param filePath - Absolute path to the target file.
17187
+ * @param content - New content to write.
17188
+ * @param options - Write mode and optional configuration.
17189
+ */
17190
+ async function updateManagedSection(filePath, content, options = {}) {
17191
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
17192
+ if (mode === 'section' && !sectionId) {
17193
+ throw new Error('sectionId is required when mode is "section"');
17194
+ }
17195
+ const dir = dirname(filePath);
17196
+ if (!existsSync(dir)) {
17197
+ mkdirSync(dir, { recursive: true });
17198
+ }
17199
+ // Create file if it doesn't exist
17200
+ if (!existsSync(filePath)) {
17201
+ writeFileSync(filePath, '', 'utf-8');
17202
+ }
17203
+ try {
17204
+ await withFileLock(filePath, () => {
17205
+ const fileContent = readFileSync(filePath, 'utf-8');
17206
+ const parsed = parseManaged(fileContent, markers);
17207
+ // Version-stamp convergence check (block mode only).
17208
+ // In section mode, components always write their own sections — the version
17209
+ // stamp governs shared content convergence, not component-specific sections.
17210
+ if (mode === 'block' &&
17211
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
17212
+ return;
17213
+ }
17214
+ let newManagedBody;
17215
+ if (mode === 'block') {
17216
+ // Prepend H1 title if markers specify one
17217
+ newManagedBody = markers.title
17218
+ ? `# ${markers.title}\n\n${content}`
17219
+ : content;
17220
+ }
17221
+ else {
17222
+ // Section mode: upsert the named section
17223
+ const sections = [...parsed.sections];
17224
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
17225
+ if (existingIdx >= 0) {
17226
+ sections[existingIdx] = { id: sectionId, content };
17227
+ }
17228
+ else {
17229
+ sections.push({ id: sectionId, content });
17230
+ }
17231
+ sortSectionsByOrder(sections);
17232
+ const sectionText = sections
17233
+ .map((s) => `## ${s.id}\n\n${s.content}`)
17234
+ .join('\n\n');
17235
+ // Prepend H1 title if markers specify one
17236
+ newManagedBody = markers.title
17237
+ ? `# ${markers.title}\n\n${sectionText}`
17238
+ : sectionText;
17239
+ }
17240
+ // Build the full managed block
17241
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
17242
+ const endLine = formatEndMarker(markers.end);
17243
+ // Combine all user content for cleanup detection
17244
+ const rawUserContent = [parsed.beforeContent, parsed.userContent]
17245
+ .filter(Boolean)
17246
+ .join('\n\n')
17247
+ .trim();
17248
+ // Strip foreign managed blocks from user content (cross-contamination fix)
17249
+ const userContent = stripForeignMarkers(rawUserContent, markers);
17250
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
17251
+ const managedParts = [];
17252
+ managedParts.push(beginLine);
17253
+ if (cleanupNeeded) {
17254
+ managedParts.push('');
17255
+ managedParts.push(CLEANUP_FLAG);
17256
+ }
17257
+ managedParts.push('');
17258
+ managedParts.push(newManagedBody);
17259
+ managedParts.push('');
17260
+ managedParts.push(endLine);
17261
+ const managedBlock = managedParts.join('\n');
17262
+ let newFileContent;
17263
+ if (parsed.found) {
17264
+ // Existing block: update in place — preserve position, don't move.
17265
+ // Strip foreign managed blocks from both content zones (cross-contamination fix).
17266
+ const cleanBefore = stripForeignMarkers(parsed.beforeContent, markers);
17267
+ const cleanAfter = stripForeignMarkers(parsed.userContent, markers);
17268
+ const fileParts = [];
17269
+ if (cleanBefore) {
17270
+ fileParts.push(cleanBefore);
17271
+ fileParts.push('');
17272
+ }
17273
+ fileParts.push(managedBlock);
17274
+ if (cleanAfter) {
17275
+ fileParts.push('');
17276
+ fileParts.push(cleanAfter);
17277
+ }
17278
+ fileParts.push('');
17279
+ newFileContent = fileParts.join('\n');
17280
+ }
17281
+ else {
17282
+ // No existing block: insert new block using the configured position.
17283
+ // Strip orphaned same-type BEGIN markers from user content to prevent
17284
+ // the parser from pairing them with the new END marker on the next cycle.
17285
+ const orphanedBeginRe = new RegExp(`^<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$(?:\\r?\\n)?`, 'gm');
17286
+ const cleanUserContent = userContent
17287
+ .replace(orphanedBeginRe, '')
17288
+ .replace(/\n{3,}/g, '\n\n')
17289
+ .trim();
17290
+ const position = markers.position ?? 'top';
17291
+ const fileParts = [];
17292
+ if (position === 'bottom') {
17293
+ if (cleanUserContent) {
17294
+ fileParts.push(cleanUserContent);
17295
+ fileParts.push('');
17296
+ }
17297
+ fileParts.push(managedBlock);
17298
+ }
17299
+ else {
17300
+ fileParts.push(managedBlock);
17301
+ if (cleanUserContent) {
17302
+ fileParts.push('');
17303
+ fileParts.push(cleanUserContent);
17304
+ }
17305
+ }
17306
+ fileParts.push('');
17307
+ newFileContent = fileParts.join('\n');
17308
+ }
17309
+ atomicWrite(filePath, newFileContent);
17310
+ });
17311
+ }
17312
+ catch (err) {
17313
+ // Log warning but don't throw — writer cycles are periodic
17314
+ console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${getErrorMessage(err)}`);
17315
+ }
17316
+ }
17317
+
17318
+ var agentsSectionContent = `## "I'll Note This" Is Not Noting
17319
+
17021
17320
  **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**.
17022
17321
 
17023
17322
  - Wrong: "I'll note this for the email path." → (conversation moves on, never written)
@@ -17308,1437 +17607,1138 @@ When editing files outside the workspace, use the bridge pattern: copy in → ed
17308
17607
  ### Plugin Lifecycle
17309
17608
 
17310
17609
  \`\`\`bash
17311
- # Platform bootstrap (content seeding)
17312
- npx @karmaniverous/jeeves install
17313
-
17314
- # Component plugin install
17315
- npx @karmaniverous/jeeves-{component}-openclaw install
17316
-
17317
- # Component plugin uninstall
17318
- npx @karmaniverous/jeeves-{component}-openclaw uninstall
17319
-
17320
- # Platform teardown (remove managed sections)
17321
- npx @karmaniverous/jeeves uninstall
17322
- \`\`\`
17323
-
17324
- Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
17325
-
17326
- ### Reference Templates
17327
-
17328
- <!-- IF_TEMPLATES -->
17329
- Reference templates are available at \`__TEMPLATE_PATH__\`:
17330
-
17331
- | Template | Purpose |
17332
- |----------|---------|
17333
- | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
17334
- | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
17335
-
17336
- Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
17337
- <!-- ELSE_TEMPLATES -->
17338
- > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
17339
- <!-- ENDIF_TEMPLATES -->
17340
- `;
17341
-
17342
- /**
17343
- * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
17344
- *
17345
- * @remarks
17346
- * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
17347
- * Reads content files from the package's `content/` directory, renders the
17348
- * Platform template with live data, and writes managed sections using
17349
- * `updateManagedSection`.
17350
- */
17351
- /**
17352
- * Resolve the package's content directory for template file copying.
17353
- *
17354
- * @remarks
17355
- * Templates are actual files that need to be copied to the config directory.
17356
- * This only works when core is in `node_modules` (CLI install, service).
17357
- * When bundled into a consumer plugin, returns undefined and template
17358
- * copying is skipped (templates are seeded by `jeeves install`, not plugins).
17359
- *
17360
- * Content `.md` files (soul, agents, platform template) are inlined at
17361
- * build time via the rollup md plugin and imported as string literals.
17362
- * They do not use this function.
17363
- *
17364
- * @returns Absolute path to the content/ directory, or undefined.
17365
- */
17366
- function getContentDir() {
17367
- const pkgDir = packageDirectorySync({
17368
- cwd: fileURLToPath(import.meta.url),
17369
- });
17370
- if (!pkgDir)
17371
- return undefined;
17372
- const dir = join(pkgDir, 'content');
17373
- return existsSync(dir) ? dir : undefined;
17374
- }
17375
- /**
17376
- * Copy templates from content/templates/ to the core config directory.
17377
- *
17378
- * @param coreConfigDir - Core config directory path.
17379
- */
17380
- function copyTemplates(coreConfigDir) {
17381
- const contentDir = getContentDir();
17382
- if (!contentDir)
17383
- return;
17384
- const sourceDir = join(contentDir, 'templates');
17385
- if (!existsSync(sourceDir))
17386
- return;
17387
- const destDir = join(coreConfigDir, TEMPLATES_DIR);
17388
- if (!existsSync(destDir)) {
17389
- mkdirSync(destDir, { recursive: true });
17390
- }
17391
- cpSync(sourceDir, destDir, { recursive: true });
17392
- }
17393
- /**
17394
- * Render the Platform template using simple string replacement.
17395
- *
17396
- * @param templatePath - Path to the templates directory.
17397
- * @returns Rendered platform content string.
17398
- */
17399
- function renderPlatformTemplate(templatePath) {
17400
- const templatesAvailable = existsSync(templatePath);
17401
- let content = toolsPlatformTemplate;
17402
- // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
17403
- const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
17404
- const match = ifRegex.exec(content);
17405
- if (match) {
17406
- content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
17407
- }
17408
- // Replace __TEMPLATE_PATH__ with the actual path
17409
- content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
17410
- return content;
17411
- }
17412
- /**
17413
- * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
17414
- *
17415
- * @param options - Configuration for the refresh cycle.
17416
- */
17417
- async function refreshPlatformContent(options) {
17418
- const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
17419
- const workspacePath = getWorkspacePath();
17420
- const coreConfigDir = getCoreConfigDir();
17421
- // 1. Write calling component's version entry
17422
- if (componentName) {
17423
- writeComponentVersion(coreConfigDir, {
17424
- componentName,
17425
- pluginVersion: componentVersion,
17426
- servicePackage,
17427
- pluginPackage,
17428
- });
17429
- }
17430
- // 2. Render Platform template
17431
- const templatePath = join(coreConfigDir, TEMPLATES_DIR);
17432
- const platformContent = renderPlatformTemplate(templatePath);
17433
- // 3. Write TOOLS.md Platform section
17434
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
17435
- await updateManagedSection(toolsPath, platformContent, {
17436
- mode: 'section',
17437
- sectionId: 'Platform',
17438
- markers: TOOLS_MARKERS,
17439
- coreVersion,
17440
- stalenessThresholdMs,
17441
- });
17442
- // 4. Write SOUL.md managed block
17443
- const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
17444
- await updateManagedSection(soulPath, soulSectionContent, {
17445
- mode: 'block',
17446
- markers: SOUL_MARKERS,
17447
- coreVersion,
17448
- stalenessThresholdMs,
17449
- });
17450
- // 5. Write AGENTS.md managed block
17451
- const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
17452
- await updateManagedSection(agentsPath, agentsSectionContent, {
17453
- mode: 'block',
17454
- markers: AGENTS_MARKERS,
17455
- coreVersion,
17456
- stalenessThresholdMs,
17457
- });
17458
- // 6. Copy templates to config dir
17459
- copyTemplates(coreConfigDir);
17460
- }
17461
-
17462
- /**
17463
- * Cleanup-session escalation for managed files with orphaned duplicated content.
17464
- *
17465
- * @remarks
17466
- * When a managed file contains the cleanup flag, the writer can ask the
17467
- * OpenClaw gateway to spawn a background session to remove orphaned content.
17468
- * The request is best-effort: accepted requests return `true`; any transport
17469
- * or HTTP failure returns `false` so the file warning remains the fallback.
17470
- */
17471
- /** Timeout for cleanup-session spawn requests. */
17472
- const CLEANUP_REQUEST_TIMEOUT_MS = 5_000;
17473
- /**
17474
- * Build the cleanup task prompt sent to the gateway session API.
17475
- *
17476
- * @param filePath - Managed file requiring cleanup.
17477
- * @param markerIdentity - Marker identity for the file.
17478
- * @returns Cleanup instructions for the spawned session.
17479
- */
17480
- function buildCleanupTask(filePath, markerIdentity) {
17481
- return [
17482
- `Clean up orphaned managed content in ${filePath}.`,
17483
- `The file uses ${markerIdentity} managed comment markers.`,
17484
- 'Review content outside the managed block and remove only duplicated managed content.',
17485
- 'Preserve any unique user-authored content outside the managed block.',
17486
- 'Do not modify content inside the managed block unless required to preserve valid marker structure.',
17487
- ].join(' ');
17488
- }
17489
- /**
17490
- * Request a cleanup session from the OpenClaw gateway.
17491
- *
17492
- * @remarks
17493
- * Fire-and-forget. A 200-class response means the request was accepted.
17494
- * Any HTTP or transport failure returns `false` so the file-level cleanup
17495
- * warning remains the only signal.
17496
- *
17497
- * @param options - Cleanup request configuration.
17498
- * @returns Whether the gateway accepted the cleanup request.
17499
- */
17500
- async function requestCleanupSession(options) {
17501
- const { gatewayUrl, filePath, markerIdentity } = options;
17502
- const url = `${gatewayUrl.replace(/\/$/, '')}/sessions/spawn`;
17503
- const label = `cleanup:${basename(filePath)}`;
17504
- const body = {
17505
- task: buildCleanupTask(filePath, markerIdentity),
17506
- label,
17507
- };
17508
- try {
17509
- const response = await fetchWithTimeout(url, CLEANUP_REQUEST_TIMEOUT_MS, {
17510
- method: 'POST',
17511
- headers: { 'Content-Type': 'application/json' },
17512
- body: JSON.stringify(body),
17513
- });
17514
- return response.ok;
17515
- }
17516
- catch {
17517
- return false;
17518
- }
17519
- }
17610
+ # Platform bootstrap (content seeding)
17611
+ npx @karmaniverous/jeeves install
17520
17612
 
17521
- /**
17522
- * Cleanup flag scanning extracted from ComponentWriter.cycle().
17523
- *
17524
- * @remarks
17525
- * After writing managed files, scans each for the cleanup flag and
17526
- * fires a best-effort escalation request when a gateway URL is configured.
17527
- * Uses a `pendingCleanups` set to deduplicate in-flight requests.
17528
- */
17529
- /**
17530
- * Scan managed files for the cleanup flag and escalate when detected.
17531
- *
17532
- * @param targets - Managed files to scan.
17533
- * @param gatewayUrl - Gateway URL for session spawn.
17534
- * @param pendingCleanups - Set tracking in-flight requests (mutated).
17535
- */
17536
- function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
17537
- for (const target of targets) {
17538
- try {
17539
- if (pendingCleanups.has(target.filePath))
17540
- continue;
17541
- const fileContent = readFileSync(target.filePath, 'utf-8');
17542
- if (fileContent.includes(CLEANUP_FLAG)) {
17543
- pendingCleanups.add(target.filePath);
17544
- void requestCleanupSession({
17545
- gatewayUrl,
17546
- filePath: target.filePath,
17547
- markerIdentity: target.markerIdentity,
17548
- }).finally(() => {
17549
- pendingCleanups.delete(target.filePath);
17550
- });
17551
- }
17552
- }
17553
- catch {
17554
- // Best-effort: don't fail the cycle for escalation issues.
17555
- }
17556
- }
17557
- }
17613
+ # Component plugin install
17614
+ npx @karmaniverous/jeeves-{component}-openclaw install
17558
17615
 
17559
- /**
17560
- * Memory budget accounting and staleness detection for MEMORY.md.
17561
- *
17562
- * @remarks
17563
- * Scans MEMORY.md for ISO date patterns in H2/H3 headings and bullet items.
17564
- * Reports character count against a configured budget, warning threshold state,
17565
- * and stale section candidates. Does not auto-delete: review remains
17566
- * human- or agent-mediated (Decision 42).
17567
- */
17568
- /** ISO date pattern: YYYY-MM-DD. */
17569
- const ISO_DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
17570
- /** H2 heading pattern used to split sections. */
17571
- const H2_RE = /^## /m;
17572
- /**
17573
- * Extract the most recent ISO date from a string.
17574
- *
17575
- * @param text - Text to scan for dates.
17576
- * @returns The most recent date found, or undefined.
17577
- */
17578
- function extractMostRecentDate(text) {
17579
- const matches = text.match(ISO_DATE_RE);
17580
- if (!matches)
17581
- return undefined;
17582
- let latest;
17583
- for (const match of matches) {
17584
- const d = new Date(match + 'T00:00:00Z');
17585
- if (!Number.isNaN(d.getTime())) {
17586
- if (!latest || d > latest)
17587
- latest = d;
17588
- }
17589
- }
17590
- return latest;
17591
- }
17592
- /**
17593
- * Analyze MEMORY.md for budget and staleness.
17594
- *
17595
- * @param options - Analysis configuration.
17596
- * @returns Memory hygiene result.
17597
- */
17598
- function analyzeMemory(options) {
17599
- const { workspacePath, budget, warningThreshold, staleDays } = options;
17600
- const memoryPath = join(workspacePath, WORKSPACE_FILES.memory);
17601
- if (!existsSync(memoryPath)) {
17602
- return {
17603
- exists: false,
17604
- charCount: 0,
17605
- budget,
17606
- usage: 0,
17607
- warning: false,
17608
- overBudget: false,
17609
- staleCandidates: 0,
17610
- staleSectionNames: [],
17611
- };
17612
- }
17613
- const content = readFileSync(memoryPath, 'utf-8');
17614
- const charCount = content.length;
17615
- const usage = budget > 0 ? charCount / budget : charCount > 0 ? Infinity : 0;
17616
- const warning = usage >= warningThreshold;
17617
- const overBudget = usage > 1;
17618
- // Split into H2 sections and scan for staleness
17619
- const sections = content.split(H2_RE).slice(1); // skip content before first H2
17620
- const now = Date.now();
17621
- const thresholdMs = staleDays * 24 * 60 * 60 * 1000;
17622
- const staleSectionNames = [];
17623
- for (const section of sections) {
17624
- const sectionName = section.split('\n')[0]?.trim() ?? '';
17625
- const recentDate = extractMostRecentDate(section);
17626
- // Sections without dates are evergreen — never flagged (Decision 47)
17627
- if (!recentDate)
17628
- continue;
17629
- if (now - recentDate.getTime() > thresholdMs) {
17630
- staleSectionNames.push(sectionName);
17631
- }
17632
- }
17633
- return {
17634
- exists: true,
17635
- charCount,
17636
- budget,
17637
- usage,
17638
- warning,
17639
- overBudget,
17640
- staleCandidates: staleSectionNames.length,
17641
- staleSectionNames,
17642
- };
17643
- }
17616
+ # Component plugin uninstall
17617
+ npx @karmaniverous/jeeves-{component}-openclaw uninstall
17644
17618
 
17645
- /**
17646
- * HEARTBEAT integration for memory hygiene.
17647
- *
17648
- * @remarks
17649
- * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
17650
- * suitable for inclusion in the HEARTBEAT.md platform status section.
17651
- * Returns `undefined` when MEMORY.md is healthy (no alert needed).
17652
- *
17653
- * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
17654
- * alerts from component alerts (`## jeeves-{name}`).
17655
- */
17656
- /** The HEARTBEAT heading name for memory alerts. */
17657
- const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
17658
- /**
17659
- * Check memory health and return a HEARTBEAT entry if unhealthy.
17660
- *
17661
- * @param options - Memory hygiene options (workspacePath, budget, etc.).
17662
- * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
17663
- */
17664
- function checkMemoryHealth(options) {
17665
- const result = analyzeMemory(options);
17666
- if (!result.exists)
17667
- return undefined;
17668
- if (!result.warning && result.staleCandidates === 0)
17669
- return undefined;
17670
- const lines = [];
17671
- if (result.warning) {
17672
- const pct = Math.round(result.usage * 100);
17673
- lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
17674
- }
17675
- if (result.staleCandidates > 0) {
17676
- lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
17677
- }
17678
- return {
17679
- name: MEMORY_HEARTBEAT_NAME,
17680
- declined: false,
17681
- content: lines.join('\n'),
17682
- };
17683
- }
17619
+ # Platform teardown (remove managed sections)
17620
+ npx @karmaniverous/jeeves uninstall
17621
+ \`\`\`
17684
17622
 
17685
- /**
17686
- * HEARTBEAT integration for workspace file size monitoring.
17687
- *
17688
- * @remarks
17689
- * Checks all injected workspace files (AGENTS.md, SOUL.md, TOOLS.md,
17690
- * MEMORY.md, USER.md) against the OpenClaw ~20,000-char injection limit.
17691
- * Files exceeding the warning threshold generate HEARTBEAT entries with
17692
- * trimming guidance.
17693
- */
17694
- /** Workspace files monitored for size budget. */
17695
- const WORKSPACE_SIZE_FILES = [
17696
- 'AGENTS.md',
17697
- 'SOUL.md',
17698
- 'TOOLS.md',
17699
- 'MEMORY.md',
17700
- 'USER.md',
17701
- ];
17702
- /** Trimming guidance lines emitted in HEARTBEAT entries. */
17703
- const TRIMMING_GUIDANCE = [
17704
- ' 1. Move domain-specific content to a local skill',
17705
- ' 2. Extract reference material to companion files with a pointer',
17706
- ' 3. Summarize verbose instructions',
17707
- ' 4. Remove stale content',
17708
- ].join('\n');
17709
- /**
17710
- * Check all workspace files against the character budget.
17711
- *
17712
- * @param options - Health check options.
17713
- * @returns Array of results, one per checked file (skips non-existent files
17714
- * unless they breach the budget, which they cannot by definition).
17715
- */
17716
- function checkWorkspaceFileHealth(options) {
17717
- const { workspacePath, budgetChars = 20_000, warningThreshold = 0.8, } = options;
17718
- return WORKSPACE_SIZE_FILES.map((file) => {
17719
- const filePath = join(workspacePath, file);
17720
- if (!existsSync(filePath)) {
17721
- return {
17722
- file,
17723
- exists: false,
17724
- charCount: 0,
17725
- budget: budgetChars,
17726
- usage: 0,
17727
- warning: false,
17728
- overBudget: false,
17729
- };
17730
- }
17731
- const content = readFileSync(filePath, 'utf-8');
17732
- const charCount = content.length;
17733
- const usage = charCount / budgetChars;
17734
- return {
17735
- file,
17736
- exists: true,
17737
- charCount,
17738
- budget: budgetChars,
17739
- usage,
17740
- warning: usage >= warningThreshold,
17741
- overBudget: charCount > budgetChars,
17742
- };
17743
- });
17744
- }
17745
- /**
17746
- * Convert workspace file health results into HEARTBEAT entries.
17747
- *
17748
- * @param results - Results from `checkWorkspaceFileHealth`.
17749
- * @returns Array of `HeartbeatEntry` objects for files that exceed the
17750
- * warning threshold.
17751
- */
17752
- function workspaceFileHealthEntries(results) {
17753
- return results
17754
- .filter((r) => r.exists && r.warning)
17755
- .map((r) => {
17756
- const pct = Math.round(r.usage * 100);
17757
- const overBudgetNote = r.overBudget ? ' **Over budget.**' : '';
17758
- const content = [
17759
- `- Budget: ${r.charCount.toLocaleString()} / ${r.budget.toLocaleString()} chars (${String(pct)}%).${overBudgetNote} Trim to stay under the OpenClaw injection limit.`,
17760
- `- Suggested trimming priority:\n${TRIMMING_GUIDANCE}`,
17761
- ].join('\n');
17762
- return {
17763
- name: r.file,
17764
- declined: false,
17765
- content,
17766
- };
17767
- });
17768
- }
17623
+ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
17624
+
17625
+ ### Reference Templates
17626
+
17627
+ <!-- IF_TEMPLATES -->
17628
+ Reference templates are available at \`__TEMPLATE_PATH__\`:
17629
+
17630
+ | Template | Purpose |
17631
+ |----------|---------|
17632
+ | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
17633
+ | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
17634
+
17635
+ Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
17636
+ <!-- ELSE_TEMPLATES -->
17637
+ > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
17638
+ <!-- ENDIF_TEMPLATES -->
17639
+ `;
17769
17640
 
17770
17641
  /**
17771
- * Core configuration schema and resolution.
17642
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
17772
17643
  *
17773
17644
  * @remarks
17774
- * Core config lives at `{configRoot}/jeeves-core/config.json`.
17775
- * Config resolution order:
17776
- * 1. Component's own config file
17777
- * 2. Core config file
17778
- * 3. Hardcoded library defaults
17645
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
17646
+ * Reads content files from the package's `content/` directory, renders the
17647
+ * Platform template with live data, and writes managed sections using
17648
+ * `updateManagedSection`.
17779
17649
  */
17780
- /** Zod schema for a service entry in core config. */
17781
- const serviceEntrySchema = object({
17782
- /** Service URL (must be a valid URL). */
17783
- url: url().describe('Service URL'),
17784
- });
17785
- /** Default bind address for all Jeeves services. */
17786
- const DEFAULT_BIND_ADDRESS = '0.0.0.0';
17787
- /** Zod schema for the core config file. */
17788
- const coreConfigSchema = object({
17789
- /** JSON Schema pointer for IDE autocomplete. */
17790
- $schema: string().optional().describe('JSON Schema pointer'),
17791
- /** Owner identity keys (canonical identityLinks references). */
17792
- owners: array(string()).default([]).describe('Owner identity keys'),
17793
- /**
17794
- * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
17795
- * Individual components can override in their own config.
17796
- */
17797
- bindAddress: string()
17798
- .default(DEFAULT_BIND_ADDRESS)
17799
- .describe('Bind address for all Jeeves services'),
17800
- /** Service URL overrides keyed by service name. */
17801
- services: record(string(), serviceEntrySchema)
17802
- .default({})
17803
- .describe('Service URL overrides'),
17804
- /** Registry cache configuration. */
17805
- registryCache: object({
17806
- /** Cache TTL in seconds for npm registry queries. */
17807
- ttlSeconds: number()
17808
- .int()
17809
- .positive()
17810
- .default(3600)
17811
- .describe('Cache TTL in seconds'),
17812
- })
17813
- .prefault({})
17814
- .describe('Registry cache settings'),
17815
- });
17816
17650
  /**
17817
- * Load and parse a config file, returning undefined if missing or invalid.
17651
+ * Resolve the package's content directory for template file copying.
17818
17652
  *
17819
- * @param configDir - Directory containing config.json.
17820
- * @returns Parsed config or undefined.
17653
+ * @remarks
17654
+ * Templates are actual files that need to be copied to the config directory.
17655
+ * This only works when core is in `node_modules` (CLI install, service).
17656
+ * When bundled into a consumer plugin, returns undefined and template
17657
+ * copying is skipped (templates are seeded by `jeeves install`, not plugins).
17658
+ *
17659
+ * Content `.md` files (soul, agents, platform template) are inlined at
17660
+ * build time via the rollup md plugin and imported as string literals.
17661
+ * They do not use this function.
17662
+ *
17663
+ * @returns Absolute path to the content/ directory, or undefined.
17821
17664
  */
17822
- function loadConfig(configDir) {
17823
- const configPath = join(configDir, CONFIG_FILE);
17824
- if (!existsSync(configPath))
17825
- return undefined;
17826
- try {
17827
- const raw = readFileSync(configPath, 'utf-8');
17828
- const parsed = JSON.parse(raw);
17829
- return coreConfigSchema.parse(parsed);
17830
- }
17831
- catch {
17665
+ function getContentDir() {
17666
+ const pkgDir = packageDirectorySync({
17667
+ cwd: fileURLToPath(import.meta.url),
17668
+ });
17669
+ if (!pkgDir)
17832
17670
  return undefined;
17833
- }
17671
+ const dir = join(pkgDir, 'content');
17672
+ return existsSync(dir) ? dir : undefined;
17834
17673
  }
17835
-
17836
17674
  /**
17837
- * Service URL resolution.
17838
- *
17839
- * @remarks
17840
- * Resolves the URL for a named Jeeves service using the following
17841
- * resolution order:
17842
- * 1. Consumer's own component config
17843
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
17844
- * 3. Default port constants
17845
- */
17846
- /**
17847
- * Resolve the URL for a named Jeeves service.
17675
+ * Copy templates from content/templates/ to the core config directory.
17848
17676
  *
17849
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
17850
- * @param consumerName - Optional consumer component name for config override.
17851
- * @returns The resolved service URL.
17852
- * @throws Error if `init()` has not been called or the service is unknown.
17677
+ * @param coreConfigDir - Core config directory path.
17853
17678
  */
17854
- function getServiceUrl$1(serviceName, consumerName) {
17855
- // 2. Check core config
17856
- const coreDir = getCoreConfigDir();
17857
- const coreConfig = loadConfig(coreDir);
17858
- const coreUrl = coreConfig?.services[serviceName]?.url;
17859
- if (coreUrl)
17860
- return coreUrl;
17861
- // 3. Fall back to port constants
17862
- const port = DEFAULT_PORTS[serviceName];
17863
- if (port !== undefined) {
17864
- return `http://127.0.0.1:${String(port)}`;
17679
+ function copyTemplates(coreConfigDir) {
17680
+ const contentDir = getContentDir();
17681
+ if (!contentDir)
17682
+ return;
17683
+ const sourceDir = join(contentDir, 'templates');
17684
+ if (!existsSync(sourceDir))
17685
+ return;
17686
+ const destDir = join(coreConfigDir, TEMPLATES_DIR);
17687
+ if (!existsSync(destDir)) {
17688
+ mkdirSync(destDir, { recursive: true });
17865
17689
  }
17866
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
17690
+ cpSync(sourceDir, destDir, { recursive: true });
17867
17691
  }
17868
-
17869
17692
  /**
17870
- * Registry version cache for npm package update awareness.
17693
+ * Render the Platform template using simple string replacement.
17871
17694
  *
17872
- * @remarks
17873
- * Caches the latest npm registry version in a local JSON file
17874
- * to avoid expensive `npm view` calls on every refresh cycle.
17695
+ * @param templatePath - Path to the templates directory.
17696
+ * @returns Rendered platform content string.
17875
17697
  */
17698
+ function renderPlatformTemplate(templatePath) {
17699
+ const templatesAvailable = existsSync(templatePath);
17700
+ let content = toolsPlatformTemplate;
17701
+ // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
17702
+ const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
17703
+ const match = ifRegex.exec(content);
17704
+ if (match) {
17705
+ content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
17706
+ }
17707
+ // Replace __TEMPLATE_PATH__ with the actual path
17708
+ content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
17709
+ return content;
17710
+ }
17876
17711
  /**
17877
- * Check the npm registry for the latest version of a package.
17712
+ * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
17878
17713
  *
17879
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
17880
- * @param cacheDir - Directory to store the cache file.
17881
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
17882
- * @returns The latest version string, or undefined if the check fails.
17714
+ * @param options - Configuration for the refresh cycle.
17883
17715
  */
17884
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
17885
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
17886
- // Check cache first
17887
- if (existsSync(cachePath)) {
17888
- try {
17889
- const raw = readFileSync(cachePath, 'utf-8');
17890
- const entry = JSON.parse(raw);
17891
- const age = Date.now() - new Date(entry.checkedAt).getTime();
17892
- if (age < ttlSeconds * 1000) {
17893
- return entry.version;
17894
- }
17895
- }
17896
- catch {
17897
- // Cache corrupt — proceed with fresh check
17898
- }
17899
- }
17900
- // Query npm registry
17901
- try {
17902
- const result = execSync(`npm view ${packageName} version`, {
17903
- encoding: 'utf-8',
17904
- timeout: 15_000,
17905
- stdio: ['pipe', 'pipe', 'pipe'],
17906
- }).trim();
17907
- if (!result)
17908
- return undefined;
17909
- // Write cache
17910
- if (!existsSync(cacheDir)) {
17911
- mkdirSync(cacheDir, { recursive: true });
17912
- }
17913
- const entry = {
17914
- version: result,
17915
- checkedAt: new Date().toISOString(),
17916
- };
17917
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
17918
- return result;
17919
- }
17920
- catch {
17921
- return undefined;
17716
+ async function refreshPlatformContent(options) {
17717
+ const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
17718
+ const workspacePath = getWorkspacePath();
17719
+ const coreConfigDir = getCoreConfigDir();
17720
+ // 1. Write calling component's version entry
17721
+ if (componentName) {
17722
+ writeComponentVersion(coreConfigDir, {
17723
+ componentName,
17724
+ pluginVersion: componentVersion,
17725
+ servicePackage,
17726
+ pluginPackage,
17727
+ });
17922
17728
  }
17729
+ // 2. Render Platform template
17730
+ const templatePath = join(coreConfigDir, TEMPLATES_DIR);
17731
+ const platformContent = renderPlatformTemplate(templatePath);
17732
+ // 3. Write TOOLS.md Platform section
17733
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
17734
+ await updateManagedSection(toolsPath, platformContent, {
17735
+ mode: 'section',
17736
+ sectionId: 'Platform',
17737
+ markers: TOOLS_MARKERS,
17738
+ coreVersion,
17739
+ stalenessThresholdMs,
17740
+ });
17741
+ // 4. Write SOUL.md managed block
17742
+ const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
17743
+ await updateManagedSection(soulPath, soulSectionContent, {
17744
+ mode: 'block',
17745
+ markers: SOUL_MARKERS,
17746
+ coreVersion,
17747
+ stalenessThresholdMs,
17748
+ });
17749
+ // 5. Write AGENTS.md managed block
17750
+ const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
17751
+ await updateManagedSection(agentsPath, agentsSectionContent, {
17752
+ mode: 'block',
17753
+ markers: AGENTS_MARKERS,
17754
+ coreVersion,
17755
+ stalenessThresholdMs,
17756
+ });
17757
+ // 6. Copy templates to config dir
17758
+ copyTemplates(coreConfigDir);
17923
17759
  }
17924
17760
 
17925
17761
  /**
17926
- * HEARTBEAT health orchestration.
17762
+ * Cleanup-session escalation for managed files with orphaned duplicated content.
17763
+ *
17764
+ * @remarks
17765
+ * When a managed file contains the cleanup flag, the writer can ask the
17766
+ * OpenClaw gateway to spawn a background session to remove orphaned content.
17767
+ * The request is best-effort: accepted requests return `true`; any transport
17768
+ * or HTTP failure returns `false` so the file warning remains the fallback.
17769
+ */
17770
+ /** Timeout for cleanup-session spawn requests. */
17771
+ const CLEANUP_REQUEST_TIMEOUT_MS = 5_000;
17772
+ /**
17773
+ * Build the cleanup task prompt sent to the gateway session API.
17927
17774
  *
17928
- * @remarks
17929
- * Determines the state of each platform component and generates
17930
- * HEARTBEAT entries with actionable alert text. Applies the dependency
17931
- * graph for alert suppression and auto-decline.
17775
+ * @param filePath - Managed file requiring cleanup.
17776
+ * @param markerIdentity - Marker identity for the file.
17777
+ * @returns Cleanup instructions for the spawned session.
17932
17778
  */
17933
- /** Derive the full service name from a component name. */
17934
- function toServiceName(name) {
17935
- return `jeeves-${name}`;
17779
+ function buildCleanupTask(filePath, markerIdentity) {
17780
+ return [
17781
+ `Clean up orphaned managed content in ${filePath}.`,
17782
+ `The file uses ${markerIdentity} managed comment markers.`,
17783
+ 'Review content outside the managed block and remove only duplicated managed content.',
17784
+ 'Preserve any unique user-authored content outside the managed block.',
17785
+ 'Do not modify content inside the managed block unless required to preserve valid marker structure.',
17786
+ ].join(' ');
17936
17787
  }
17937
- /** Known dependency declarations for platform components. */
17938
- const COMPONENT_DEPS = {
17939
- meta: { hard: ['watcher'], soft: [] },
17940
- server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
17941
- runner: { hard: [], soft: [] },
17942
- watcher: { hard: [], soft: [] },
17943
- };
17944
- /** "Not installed" alert text for each platform component. Shared with seedContent. */
17945
- const NOT_INSTALLED_ALERTS = {
17946
- 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`.',
17947
- 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`.',
17948
- 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`.',
17949
- 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`.',
17950
- };
17951
- /** Alert text generators by state. */
17952
- const ALERT_TEXT = {
17953
- runner: {
17954
- not_installed: NOT_INSTALLED_ALERTS['runner'],
17955
- 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\`.`,
17956
- 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.',
17957
- 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`.',
17958
- },
17959
- watcher: {
17960
- not_installed: NOT_INSTALLED_ALERTS['watcher'],
17961
- 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`.',
17962
- 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\`.`,
17963
- 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.',
17964
- 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`.',
17965
- },
17966
- server: {
17967
- not_installed: NOT_INSTALLED_ALERTS['server'],
17968
- 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\`.`,
17969
- 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.',
17970
- 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`.',
17971
- },
17972
- meta: {
17973
- not_installed: NOT_INSTALLED_ALERTS['meta'],
17974
- 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.',
17975
- 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\`.`,
17976
- 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.',
17977
- 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`.',
17978
- },
17979
- };
17980
- /** Default Qdrant URL for watcher dependency check. */
17981
- const QDRANT_URL = 'http://127.0.0.1:6333';
17982
- /** Health probe timeout in milliseconds. */
17983
- const PROBE_TIMEOUT_MS$1 = 3000;
17984
17788
  /**
17985
- * Check if Qdrant is reachable (watcher dependency).
17789
+ * Request a cleanup session from the OpenClaw gateway.
17986
17790
  *
17987
- * @returns True if Qdrant responds.
17791
+ * @remarks
17792
+ * Fire-and-forget. A 200-class response means the request was accepted.
17793
+ * Any HTTP or transport failure returns `false` so the file-level cleanup
17794
+ * warning remains the only signal.
17795
+ *
17796
+ * @param options - Cleanup request configuration.
17797
+ * @returns Whether the gateway accepted the cleanup request.
17988
17798
  */
17989
- async function isQdrantAvailable() {
17799
+ async function requestCleanupSession(options) {
17800
+ const { gatewayUrl, filePath, markerIdentity } = options;
17801
+ const url = `${gatewayUrl.replace(/\/$/, '')}/sessions/spawn`;
17802
+ const label = `cleanup:${basename(filePath)}`;
17803
+ const body = {
17804
+ task: buildCleanupTask(filePath, markerIdentity),
17805
+ label,
17806
+ };
17990
17807
  try {
17991
- await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS$1);
17992
- return true;
17808
+ const response = await fetchWithTimeout(url, CLEANUP_REQUEST_TIMEOUT_MS, {
17809
+ method: 'POST',
17810
+ headers: { 'Content-Type': 'application/json' },
17811
+ body: JSON.stringify(body),
17812
+ });
17813
+ return response.ok;
17993
17814
  }
17994
17815
  catch {
17995
17816
  return false;
17996
17817
  }
17997
17818
  }
17819
+
17998
17820
  /**
17999
- * Determine the state of a single component.
17821
+ * Cleanup flag scanning extracted from ComponentWriter.cycle().
18000
17822
  *
18001
- * @param name - Component name.
18002
- * @param registry - Current component-versions.json contents.
18003
- * @param configRoot - Config root path.
18004
- * @param healthySet - Set of component names known to be healthy (for dep checks).
18005
- * @returns The component's state.
17823
+ * @remarks
17824
+ * After writing managed files, scans each for the cleanup flag and
17825
+ * fires a best-effort escalation request when a gateway URL is configured.
17826
+ * Uses a `pendingCleanups` set to deduplicate in-flight requests.
18006
17827
  */
18007
- async function determineComponentState(name, registry, configRoot, healthySet) {
18008
- // Not in registry = not installed
18009
- if (!(name in registry))
18010
- return 'not_installed';
18011
- // Check hard dependencies
18012
- const deps = COMPONENT_DEPS[name];
18013
- for (const hardDep of deps.hard) {
18014
- if (!healthySet.has(hardDep))
18015
- return 'deps_missing';
18016
- }
18017
- // Watcher-specific: check Qdrant
18018
- if (name === 'watcher' && !(await isQdrantAvailable())) {
18019
- return 'deps_missing';
18020
- }
18021
- // Check config file
18022
- const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
18023
- if (!existsSync(configPath))
18024
- return 'config_missing';
18025
- // Fast path: probe HTTP health endpoint
18026
- try {
18027
- const url = getServiceUrl$1(name);
18028
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
18029
- // Healthy — check for available updates
18030
- const entry = registry[name];
18031
- if (entry.pluginPackage && entry.pluginVersion) {
18032
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
18033
- const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
18034
- if (latestVersion && semverExports.gt(latestVersion, entry.pluginVersion)) {
18035
- return 'update_available';
17828
+ /**
17829
+ * Scan managed files for the cleanup flag and escalate when detected.
17830
+ *
17831
+ * @param targets - Managed files to scan.
17832
+ * @param gatewayUrl - Gateway URL for session spawn.
17833
+ * @param pendingCleanups - Set tracking in-flight requests (mutated).
17834
+ */
17835
+ function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
17836
+ for (const target of targets) {
17837
+ try {
17838
+ if (pendingCleanups.has(target.filePath))
17839
+ continue;
17840
+ const fileContent = readFileSync(target.filePath, 'utf-8');
17841
+ if (fileContent.includes(CLEANUP_FLAG)) {
17842
+ pendingCleanups.add(target.filePath);
17843
+ void requestCleanupSession({
17844
+ gatewayUrl,
17845
+ filePath: target.filePath,
17846
+ markerIdentity: target.markerIdentity,
17847
+ }).finally(() => {
17848
+ pendingCleanups.delete(target.filePath);
17849
+ });
18036
17850
  }
18037
17851
  }
18038
- return 'healthy';
18039
- }
18040
- catch {
18041
- // Service not responding — classify sub-state
18042
- const serviceState = getServiceState(toServiceName(name));
18043
- if (serviceState === 'not_installed')
18044
- return 'service_not_installed';
18045
- if (serviceState === 'stopped')
18046
- return 'service_stopped';
18047
- // serviceState === 'running' but HTTP failed — still treat as stopped
18048
- return 'service_stopped';
17852
+ catch {
17853
+ // Best-effort: don't fail the cycle for escalation issues.
17854
+ }
18049
17855
  }
18050
17856
  }
17857
+
18051
17858
  /**
18052
- * Generate the alert text for a component in a given state.
17859
+ * Memory budget accounting and staleness detection for MEMORY.md.
18053
17860
  *
18054
- * @param name - Component name.
18055
- * @param state - The component's state.
18056
- * @param configRoot - Config root path.
18057
- * @returns Alert text (list items), or empty string if healthy.
17861
+ * @remarks
17862
+ * Scans MEMORY.md for ISO date patterns in H2/H3 headings and bullet items.
17863
+ * Reports character count against a configured budget, warning threshold state,
17864
+ * and stale section candidates. Does not auto-delete: review remains
17865
+ * human- or agent-mediated (Decision 42).
18058
17866
  */
18059
- function generateAlertText(name, state, configRoot, registry) {
18060
- if (state === 'healthy')
18061
- return '';
18062
- // Update available dynamic text with version info
18063
- if (state === 'update_available') {
18064
- const entry = registry[name];
18065
- const currentVersion = entry.pluginVersion ?? 'unknown';
18066
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
18067
- const latestVersion = entry.pluginPackage
18068
- ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
18069
- 'unknown')
18070
- : 'unknown';
18071
- const installCmd = entry.pluginPackage
18072
- ? `\`npx ${entry.pluginPackage} install\``
18073
- : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
18074
- return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
18075
- }
18076
- const componentAlerts = ALERT_TEXT[name];
18077
- const alertOrFn = componentAlerts[state];
18078
- if (!alertOrFn)
18079
- return '';
18080
- const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
18081
- return `- ${text}`;
18082
- }
17867
+ /** ISO date pattern: YYYY-MM-DD. */
17868
+ const ISO_DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
17869
+ /** H2 heading pattern used to split sections. */
17870
+ const H2_RE = /^## /m;
18083
17871
  /**
18084
- * Orchestrate HEARTBEAT entries for all platform components.
17872
+ * Extract the most recent ISO date from a string.
18085
17873
  *
18086
- * @param options - Orchestration configuration.
18087
- * @returns Array of HeartbeatEntry for writeHeartbeatSection.
17874
+ * @param text - Text to scan for dates.
17875
+ * @returns The most recent date found, or undefined.
18088
17876
  */
18089
- async function orchestrateHeartbeat(options) {
18090
- const { coreConfigDir, configRoot, declinedNames } = options;
18091
- const registry = readComponentVersions(coreConfigDir);
18092
- // First pass: determine which components are healthy (for dep resolution)
18093
- const healthySet = new Set();
18094
- for (const name of PLATFORM_COMPONENTS) {
18095
- if (declinedNames.has(toServiceName(name)))
18096
- continue;
18097
- if (!(name in registry))
18098
- continue;
18099
- try {
18100
- const url = getServiceUrl$1(name);
18101
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
18102
- healthySet.add(name);
18103
- }
18104
- catch {
18105
- // Not healthy — will be classified in second pass
18106
- }
18107
- }
18108
- // Second pass: generate entries
18109
- const entries = [];
18110
- for (const name of PLATFORM_COMPONENTS) {
18111
- const fullName = toServiceName(name);
18112
- // Declined
18113
- if (declinedNames.has(fullName)) {
18114
- // Auto-decline dependents of declined hard deps
18115
- entries.push({ name: fullName, declined: true, content: '' });
18116
- continue;
18117
- }
18118
- const state = await determineComponentState(name, registry, configRoot, healthySet);
18119
- // Auto-decline if hard dep is declined
18120
- const deps = COMPONENT_DEPS[name];
18121
- const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
18122
- if (hardDepDeclined) {
18123
- entries.push({ name: fullName, declined: true, content: '' });
18124
- continue;
17877
+ function extractMostRecentDate(text) {
17878
+ const matches = text.match(ISO_DATE_RE);
17879
+ if (!matches)
17880
+ return undefined;
17881
+ let latest;
17882
+ for (const match of matches) {
17883
+ const d = new Date(match + 'T00:00:00Z');
17884
+ if (!Number.isNaN(d.getTime())) {
17885
+ if (!latest || d > latest)
17886
+ latest = d;
18125
17887
  }
18126
- const alertText = generateAlertText(name, state, configRoot, registry);
18127
- entries.push({ name: fullName, declined: false, content: alertText });
18128
17888
  }
18129
- // Add soft-dep informational alerts for any healthy component with soft deps
18130
- for (const entry of entries) {
18131
- if (entry.declined || entry.content)
18132
- continue;
18133
- // Entry is healthy (no alert, not declined) — check for soft deps
18134
- const shortName = entry.name.replace(/^jeeves-/, '');
18135
- const deps = COMPONENT_DEPS[shortName];
18136
- if (!deps.soft.length)
17889
+ return latest;
17890
+ }
17891
+ /**
17892
+ * Analyze MEMORY.md for budget and staleness.
17893
+ *
17894
+ * @param options - Analysis configuration.
17895
+ * @returns Memory hygiene result.
17896
+ */
17897
+ function analyzeMemory(options) {
17898
+ const { workspacePath, budget, warningThreshold, staleDays } = options;
17899
+ const memoryPath = join(workspacePath, WORKSPACE_FILES.memory);
17900
+ if (!existsSync(memoryPath)) {
17901
+ return {
17902
+ exists: false,
17903
+ charCount: 0,
17904
+ budget,
17905
+ usage: 0,
17906
+ warning: false,
17907
+ overBudget: false,
17908
+ staleCandidates: 0,
17909
+ staleSectionNames: [],
17910
+ };
17911
+ }
17912
+ const content = readFileSync(memoryPath, 'utf-8');
17913
+ const charCount = content.length;
17914
+ const usage = budget > 0 ? charCount / budget : charCount > 0 ? Infinity : 0;
17915
+ const warning = usage >= warningThreshold;
17916
+ const overBudget = usage > 1;
17917
+ // Split into H2 sections and scan for staleness
17918
+ const sections = content.split(H2_RE).slice(1); // skip content before first H2
17919
+ const now = Date.now();
17920
+ const thresholdMs = staleDays * 24 * 60 * 60 * 1000;
17921
+ const staleSectionNames = [];
17922
+ for (const section of sections) {
17923
+ const sectionName = section.split('\n')[0]?.trim() ?? '';
17924
+ const recentDate = extractMostRecentDate(section);
17925
+ // Sections without dates are evergreen — never flagged (Decision 47)
17926
+ if (!recentDate)
18137
17927
  continue;
18138
- const softAlerts = [];
18139
- for (const dep of deps.soft) {
18140
- const depFullName = toServiceName(dep);
18141
- if (declinedNames.has(depFullName))
18142
- continue;
18143
- if (!healthySet.has(dep)) {
18144
- softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
18145
- }
18146
- }
18147
- if (softAlerts.length > 0) {
18148
- entry.content = softAlerts.join('\n');
17928
+ if (now - recentDate.getTime() > thresholdMs) {
17929
+ staleSectionNames.push(sectionName);
18149
17930
  }
18150
17931
  }
18151
- return entries;
17932
+ return {
17933
+ exists: true,
17934
+ charCount,
17935
+ budget,
17936
+ usage,
17937
+ warning,
17938
+ overBudget,
17939
+ staleCandidates: staleSectionNames.length,
17940
+ staleSectionNames,
17941
+ };
18152
17942
  }
18153
17943
 
18154
17944
  /**
18155
- * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
17945
+ * HEARTBEAT integration for memory hygiene.
18156
17946
  *
18157
17947
  * @remarks
18158
- * Reads existing HEARTBEAT.md, resolves declined components, runs the
18159
- * heartbeat state machine, and writes the result. Best-effort: failures
18160
- * are logged but do not propagate.
17948
+ * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
17949
+ * suitable for inclusion in the HEARTBEAT.md platform status section.
17950
+ * Returns `undefined` when MEMORY.md is healthy (no alert needed).
17951
+ *
17952
+ * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
17953
+ * alerts from component alerts (`## jeeves-{name}`).
18161
17954
  */
17955
+ /** The HEARTBEAT heading name for memory alerts. */
17956
+ const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
18162
17957
  /**
18163
- * Read a file's content, returning empty string if the file does not exist.
17958
+ * Check memory health and return a HEARTBEAT entry if unhealthy.
18164
17959
  *
18165
- * @param filePath - Absolute file path.
18166
- * @returns File content or empty string.
17960
+ * @param options - Memory hygiene options (workspacePath, budget, etc.).
17961
+ * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
18167
17962
  */
18168
- function readFileOrEmpty(filePath) {
18169
- try {
18170
- return readFileSync(filePath, 'utf-8');
17963
+ function checkMemoryHealth(options) {
17964
+ const result = analyzeMemory(options);
17965
+ if (!result.exists)
17966
+ return undefined;
17967
+ if (!result.warning && result.staleCandidates === 0)
17968
+ return undefined;
17969
+ const lines = [];
17970
+ if (result.warning) {
17971
+ const pct = Math.round(result.usage * 100);
17972
+ lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
18171
17973
  }
18172
- catch (err) {
18173
- if (err instanceof Error &&
18174
- 'code' in err &&
18175
- err.code === 'ENOENT') {
18176
- return '';
18177
- }
18178
- throw err;
17974
+ if (result.staleCandidates > 0) {
17975
+ lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
18179
17976
  }
17977
+ return {
17978
+ name: MEMORY_HEARTBEAT_NAME,
17979
+ declined: false,
17980
+ content: lines.join('\n'),
17981
+ };
18180
17982
  }
17983
+
18181
17984
  /**
18182
- * Run a single HEARTBEAT orchestration cycle.
17985
+ * HEARTBEAT integration for workspace file size monitoring.
18183
17986
  *
18184
- * @param options - Heartbeat cycle configuration.
17987
+ * @remarks
17988
+ * Checks all injected workspace files (AGENTS.md, SOUL.md, TOOLS.md,
17989
+ * MEMORY.md, USER.md) against the OpenClaw ~20,000-char injection limit.
17990
+ * Files exceeding the warning threshold generate HEARTBEAT entries with
17991
+ * trimming guidance.
17992
+ */
17993
+ /** Workspace files monitored for size budget. */
17994
+ const WORKSPACE_SIZE_FILES = [
17995
+ 'AGENTS.md',
17996
+ 'SOUL.md',
17997
+ 'TOOLS.md',
17998
+ 'MEMORY.md',
17999
+ 'USER.md',
18000
+ ];
18001
+ /** Trimming guidance lines emitted in HEARTBEAT entries. */
18002
+ const TRIMMING_GUIDANCE = [
18003
+ ' 1. Move domain-specific content to a local skill',
18004
+ ' 2. Extract reference material to companion files with a pointer',
18005
+ ' 3. Summarize verbose instructions',
18006
+ ' 4. Remove stale content',
18007
+ ].join('\n');
18008
+ /**
18009
+ * Check all workspace files against the character budget.
18010
+ *
18011
+ * @param options - Health check options.
18012
+ * @returns Array of results, one per checked file (skips non-existent files
18013
+ * unless they breach the budget, which they cannot by definition).
18014
+ */
18015
+ function checkWorkspaceFileHealth(options) {
18016
+ const { workspacePath, budgetChars = 20_000, warningThreshold = 0.8, } = options;
18017
+ return WORKSPACE_SIZE_FILES.map((file) => {
18018
+ const filePath = join(workspacePath, file);
18019
+ if (!existsSync(filePath)) {
18020
+ return {
18021
+ file,
18022
+ exists: false,
18023
+ charCount: 0,
18024
+ budget: budgetChars,
18025
+ usage: 0,
18026
+ warning: false,
18027
+ overBudget: false,
18028
+ };
18029
+ }
18030
+ const content = readFileSync(filePath, 'utf-8');
18031
+ const charCount = content.length;
18032
+ const usage = charCount / budgetChars;
18033
+ return {
18034
+ file,
18035
+ exists: true,
18036
+ charCount,
18037
+ budget: budgetChars,
18038
+ usage,
18039
+ warning: usage >= warningThreshold,
18040
+ overBudget: charCount > budgetChars,
18041
+ };
18042
+ });
18043
+ }
18044
+ /**
18045
+ * Convert workspace file health results into HEARTBEAT entries.
18046
+ *
18047
+ * @param results - Results from `checkWorkspaceFileHealth`.
18048
+ * @returns Array of `HeartbeatEntry` objects for files that exceed the
18049
+ * warning threshold.
18050
+ */
18051
+ function workspaceFileHealthEntries(results) {
18052
+ return results
18053
+ .filter((r) => r.exists && r.warning)
18054
+ .map((r) => {
18055
+ const pct = Math.round(r.usage * 100);
18056
+ const overBudgetNote = r.overBudget ? ' **Over budget.**' : '';
18057
+ const content = [
18058
+ `- Budget: ${r.charCount.toLocaleString()} / ${r.budget.toLocaleString()} chars (${String(pct)}%).${overBudgetNote} Trim to stay under the OpenClaw injection limit.`,
18059
+ `- Suggested trimming priority:\n${TRIMMING_GUIDANCE}`,
18060
+ ].join('\n');
18061
+ return {
18062
+ name: r.file,
18063
+ declined: false,
18064
+ content,
18065
+ };
18066
+ });
18067
+ }
18068
+
18069
+ /**
18070
+ * Core configuration schema and resolution.
18071
+ *
18072
+ * @remarks
18073
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
18074
+ * Config resolution order:
18075
+ * 1. Component's own config file
18076
+ * 2. Core config file
18077
+ * 3. Hardcoded library defaults
18078
+ */
18079
+ /** Zod schema for a service entry in core config. */
18080
+ const serviceEntrySchema = object({
18081
+ /** Service URL (must be a valid URL). */
18082
+ url: url().describe('Service URL'),
18083
+ });
18084
+ /** Default bind address for all Jeeves services. */
18085
+ const DEFAULT_BIND_ADDRESS = '0.0.0.0';
18086
+ /** Zod schema for the core config file. */
18087
+ const coreConfigSchema = object({
18088
+ /** JSON Schema pointer for IDE autocomplete. */
18089
+ $schema: string().optional().describe('JSON Schema pointer'),
18090
+ /** Owner identity keys (canonical identityLinks references). */
18091
+ owners: array(string()).default([]).describe('Owner identity keys'),
18092
+ /**
18093
+ * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
18094
+ * Individual components can override in their own config.
18095
+ */
18096
+ bindAddress: string()
18097
+ .default(DEFAULT_BIND_ADDRESS)
18098
+ .describe('Bind address for all Jeeves services'),
18099
+ /** Service URL overrides keyed by service name. */
18100
+ services: record(string(), serviceEntrySchema)
18101
+ .default({})
18102
+ .describe('Service URL overrides'),
18103
+ /** Registry cache configuration. */
18104
+ registryCache: object({
18105
+ /** Cache TTL in seconds for npm registry queries. */
18106
+ ttlSeconds: number()
18107
+ .int()
18108
+ .positive()
18109
+ .default(3600)
18110
+ .describe('Cache TTL in seconds'),
18111
+ })
18112
+ .prefault({})
18113
+ .describe('Registry cache settings'),
18114
+ });
18115
+ /**
18116
+ * Load and parse a config file, returning undefined if missing or invalid.
18117
+ *
18118
+ * @param configDir - Directory containing config.json.
18119
+ * @returns Parsed config or undefined.
18185
18120
  */
18186
- async function runHeartbeatCycle(options) {
18187
- const { workspacePath, coreConfigDir, configRoot } = options;
18188
- const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
18121
+ function loadConfig(configDir) {
18122
+ const configPath = join(configDir, CONFIG_FILE);
18123
+ if (!existsSync(configPath))
18124
+ return undefined;
18189
18125
  try {
18190
- const existingContent = readFileOrEmpty(heartbeatPath);
18191
- const parsed = parseHeartbeat(existingContent);
18192
- const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
18193
- const entries = await orchestrateHeartbeat({
18194
- coreConfigDir,
18195
- configRoot,
18196
- declinedNames,
18197
- });
18198
- // Memory hygiene check (Decision 49)
18199
- if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
18200
- const wsConfig = loadWorkspaceConfig(workspacePath);
18201
- const memoryEntry = checkMemoryHealth({
18202
- workspacePath,
18203
- budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
18204
- warningThreshold: wsConfig?.memory?.warningThreshold ??
18205
- WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
18206
- staleDays: wsConfig?.memory?.staleDays ??
18207
- WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
18208
- });
18209
- if (memoryEntry)
18210
- entries.push(memoryEntry);
18211
- }
18212
- else {
18213
- entries.push({
18214
- name: MEMORY_HEARTBEAT_NAME,
18215
- declined: true,
18216
- content: '',
18217
- });
18218
- }
18219
- // Workspace file size health check (Decision 70)
18220
- const wsFileResults = checkWorkspaceFileHealth({ workspacePath });
18221
- const wsFileAlerts = workspaceFileHealthEntries(wsFileResults);
18222
- for (const alert of wsFileAlerts) {
18223
- if (declinedNames.has(alert.name)) {
18224
- entries.push({ name: alert.name, declined: true, content: '' });
18225
- }
18226
- else {
18227
- entries.push(alert);
18228
- }
18229
- }
18230
- await writeHeartbeatSection(heartbeatPath, entries);
18126
+ const raw = readFileSync(configPath, 'utf-8');
18127
+ const parsed = JSON.parse(raw);
18128
+ return coreConfigSchema.parse(parsed);
18231
18129
  }
18232
- catch (err) {
18233
- console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${getErrorMessage(err)}`);
18130
+ catch {
18131
+ return undefined;
18234
18132
  }
18235
18133
  }
18236
18134
 
18237
18135
  /**
18238
- * Timer-based orchestrator for managed content writing.
18136
+ * Service URL resolution.
18239
18137
  *
18240
18138
  * @remarks
18241
- * `ComponentWriter` manages a component's TOOLS.md section writes
18242
- * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
18243
- * on a configurable prime-interval timer cycle.
18139
+ * Resolves the URL for a named Jeeves service using the following
18140
+ * resolution order:
18141
+ * 1. Consumer's own component config
18142
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
18143
+ * 3. Default port constants
18244
18144
  */
18245
18145
  /**
18246
- * Orchestrates managed content writing for a single Jeeves component.
18146
+ * Resolve the URL for a named Jeeves service.
18247
18147
  *
18248
- * @remarks
18249
- * Created via {@link createComponentWriter}. Manages a timer that fires
18250
- * at the component's prime-interval, calling `generateToolsContent()`
18251
- * and `refreshPlatformContent()` on each cycle.
18148
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
18149
+ * @param consumerName - Optional consumer component name for config override.
18150
+ * @returns The resolved service URL.
18151
+ * @throws Error if `init()` has not been called or the service is unknown.
18252
18152
  */
18253
- class ComponentWriter {
18254
- timer;
18255
- jitterTimeout;
18256
- component;
18257
- configDir;
18258
- gatewayUrl;
18259
- pendingCleanups = new Set();
18260
- /** @internal */
18261
- constructor(component, options) {
18262
- this.component = component;
18263
- this.configDir = getComponentConfigDir(component.name);
18264
- this.gatewayUrl = options?.gatewayUrl;
18265
- }
18266
- /** The component's config directory path. */
18267
- get componentConfigDir() {
18268
- return this.configDir;
18269
- }
18270
- /** Whether the writer timer is currently running or pending its first cycle. */
18271
- get isRunning() {
18272
- return this.jitterTimeout !== undefined || this.timer !== undefined;
18273
- }
18274
- /**
18275
- * Start the writer timer.
18276
- *
18277
- * @remarks
18278
- * Delays the first cycle by a random jitter (0 to one full interval) to
18279
- * spread initial writes across all component plugins and reduce EPERM
18280
- * contention on startup.
18281
- */
18282
- start() {
18283
- if (this.isRunning)
18284
- return;
18285
- // Random jitter up to one full interval to spread initial writes
18286
- const intervalMs = this.component.refreshIntervalSeconds * 1000;
18287
- const jitterMs = Math.floor(Math.random() * intervalMs);
18288
- this.jitterTimeout = setTimeout(() => {
18289
- this.jitterTimeout = undefined;
18290
- void this.cycle();
18291
- this.timer = setInterval(() => void this.cycle(), intervalMs);
18292
- }, jitterMs);
18293
- }
18294
- /** Stop the writer timer. */
18295
- stop() {
18296
- if (this.jitterTimeout) {
18297
- clearTimeout(this.jitterTimeout);
18298
- this.jitterTimeout = undefined;
18299
- }
18300
- if (this.timer) {
18301
- clearInterval(this.timer);
18302
- this.timer = undefined;
18303
- }
18304
- }
18305
- /**
18306
- * Execute a single write cycle.
18307
- *
18308
- * @remarks
18309
- * 1. Write the component's TOOLS.md section.
18310
- * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
18311
- * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
18312
- * 4. Run HEARTBEAT health orchestration.
18313
- */
18314
- async cycle() {
18315
- try {
18316
- const workspacePath = getWorkspacePath();
18317
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
18318
- // 1. Write the component's TOOLS.md section
18319
- const toolsContent = this.component.generateToolsContent();
18320
- await updateManagedSection(toolsPath, toolsContent, {
18321
- mode: 'section',
18322
- sectionId: this.component.sectionId,
18323
- markers: TOOLS_MARKERS,
18324
- coreVersion: CORE_VERSION,
18325
- });
18326
- // 2. Platform content maintenance
18327
- await refreshPlatformContent({
18328
- coreVersion: CORE_VERSION,
18329
- componentName: this.component.name,
18330
- componentVersion: this.component.version,
18331
- servicePackage: this.component.servicePackage,
18332
- pluginPackage: this.component.pluginPackage,
18333
- });
18334
- // 3. Cleanup escalation
18335
- if (this.gatewayUrl) {
18336
- scanAndEscalateCleanup([
18337
- { filePath: toolsPath, markerIdentity: 'TOOLS' },
18338
- {
18339
- filePath: join(workspacePath, WORKSPACE_FILES.soul),
18340
- markerIdentity: 'SOUL',
18341
- },
18342
- {
18343
- filePath: join(workspacePath, WORKSPACE_FILES.agents),
18344
- markerIdentity: 'AGENTS',
18345
- },
18346
- ], this.gatewayUrl, this.pendingCleanups);
18347
- }
18348
- // 4. HEARTBEAT orchestration
18349
- await runHeartbeatCycle({
18350
- workspacePath,
18351
- coreConfigDir: getCoreConfigDir(),
18352
- configRoot: getConfigRoot$1(),
18353
- });
18354
- }
18355
- catch (err) {
18356
- console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${getErrorMessage(err)}`);
18357
- }
18153
+ function getServiceUrl$1(serviceName, consumerName) {
18154
+ // 2. Check core config
18155
+ const coreDir = getCoreConfigDir();
18156
+ const coreConfig = loadConfig(coreDir);
18157
+ const coreUrl = coreConfig?.services[serviceName]?.url;
18158
+ if (coreUrl)
18159
+ return coreUrl;
18160
+ // 3. Fall back to port constants
18161
+ const port = DEFAULT_PORTS[serviceName];
18162
+ if (port !== undefined) {
18163
+ return `http://127.0.0.1:${String(port)}`;
18358
18164
  }
18165
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
18359
18166
  }
18360
18167
 
18361
18168
  /**
18362
- * Creates a synchronous content accessor backed by an async data source.
18169
+ * Registry version cache for npm package update awareness.
18363
18170
  *
18364
18171
  * @remarks
18365
- * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
18366
- * the interface is synchronous, but most components fetch live data from
18367
- * their HTTP service. This utility returns a sync `() => string` that
18368
- * serves the last successfully fetched value while kicking off a background
18369
- * refresh on each call.
18370
- *
18371
- * First call returns `placeholder`. Subsequent calls return the last
18372
- * successfully fetched content. If a refresh fails, the previous good
18373
- * value is retained.
18374
- *
18375
- * @example
18376
- * ```typescript
18377
- * const getContent = createAsyncContentCache({
18378
- * fetch: async () => {
18379
- * const res = await fetch('http://127.0.0.1:1936/status');
18380
- * return formatWatcherStatus(await res.json());
18381
- * },
18382
- * placeholder: '> Initializing watcher status...',
18383
- * });
18384
- *
18385
- * const writer = createComponentWriter({
18386
- * // ...
18387
- * generateToolsContent: getContent,
18388
- * });
18389
- * ```
18172
+ * Caches the latest npm registry version in a local JSON file
18173
+ * to avoid expensive `npm view` calls on every refresh cycle.
18390
18174
  */
18391
18175
  /**
18392
- * Creates a synchronous content accessor backed by an async data source.
18176
+ * Check the npm registry for the latest version of a package.
18393
18177
  *
18394
- * @param options - Cache configuration.
18395
- * @returns A sync `() => string` suitable for `generateToolsContent`.
18178
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
18179
+ * @param cacheDir - Directory to store the cache file.
18180
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
18181
+ * @returns The latest version string, or undefined if the check fails.
18396
18182
  */
18397
- function createAsyncContentCache(options) {
18398
- const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
18399
- console.warn('[jeeves] async content cache refresh failed:', err);
18400
- }, } = options;
18401
- let cached = placeholder;
18402
- let refreshing = false;
18403
- return () => {
18404
- if (!refreshing) {
18405
- refreshing = true;
18406
- fetchContent()
18407
- .then((content) => {
18408
- cached = content;
18409
- })
18410
- .catch(onError)
18411
- .finally(() => {
18412
- refreshing = false;
18413
- });
18183
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
18184
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
18185
+ // Check cache first
18186
+ if (existsSync(cachePath)) {
18187
+ try {
18188
+ const raw = readFileSync(cachePath, 'utf-8');
18189
+ const entry = JSON.parse(raw);
18190
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
18191
+ if (age < ttlSeconds * 1000) {
18192
+ return entry.version;
18193
+ }
18414
18194
  }
18415
- return cached;
18416
- };
18195
+ catch {
18196
+ // Cache corrupt — proceed with fresh check
18197
+ }
18198
+ }
18199
+ // Query npm registry
18200
+ try {
18201
+ const result = execSync(`npm view ${packageName} version`, {
18202
+ encoding: 'utf-8',
18203
+ timeout: 15_000,
18204
+ stdio: ['pipe', 'pipe', 'pipe'],
18205
+ }).trim();
18206
+ if (!result)
18207
+ return undefined;
18208
+ // Write cache
18209
+ if (!existsSync(cacheDir)) {
18210
+ mkdirSync(cacheDir, { recursive: true });
18211
+ }
18212
+ const entry = {
18213
+ version: result,
18214
+ checkedAt: new Date().toISOString(),
18215
+ };
18216
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
18217
+ return result;
18218
+ }
18219
+ catch {
18220
+ return undefined;
18221
+ }
18417
18222
  }
18418
18223
 
18419
18224
  /**
18420
- * Factory function for creating a ComponentWriter from a descriptor.
18225
+ * HEARTBEAT health orchestration.
18421
18226
  *
18422
18227
  * @remarks
18423
- * Validates the descriptor via Zod schema and creates a ComponentWriter.
18424
- * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
18425
- * `JeevesComponent` interface is no longer accepted.
18228
+ * Determines the state of each platform component and generates
18229
+ * HEARTBEAT entries with actionable alert text. Applies the dependency
18230
+ * graph for alert suppression and auto-decline.
18426
18231
  */
18232
+ /** Derive the full service name from a component name. */
18233
+ function toServiceName(name) {
18234
+ return `jeeves-${name}`;
18235
+ }
18236
+ /** Known dependency declarations for platform components. */
18237
+ const COMPONENT_DEPS = {
18238
+ meta: { hard: ['watcher'], soft: [] },
18239
+ server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
18240
+ runner: { hard: [], soft: [] },
18241
+ watcher: { hard: [], soft: [] },
18242
+ };
18243
+ /** "Not installed" alert text for each platform component. Shared with seedContent. */
18244
+ const NOT_INSTALLED_ALERTS = {
18245
+ 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`.',
18246
+ 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`.',
18247
+ 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`.',
18248
+ 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`.',
18249
+ };
18250
+ /** Alert text generators by state. */
18251
+ const ALERT_TEXT = {
18252
+ runner: {
18253
+ not_installed: NOT_INSTALLED_ALERTS['runner'],
18254
+ 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\`.`,
18255
+ 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.',
18256
+ 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`.',
18257
+ },
18258
+ watcher: {
18259
+ not_installed: NOT_INSTALLED_ALERTS['watcher'],
18260
+ 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`.',
18261
+ 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\`.`,
18262
+ 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.',
18263
+ 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`.',
18264
+ },
18265
+ server: {
18266
+ not_installed: NOT_INSTALLED_ALERTS['server'],
18267
+ 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\`.`,
18268
+ 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.',
18269
+ 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`.',
18270
+ },
18271
+ meta: {
18272
+ not_installed: NOT_INSTALLED_ALERTS['meta'],
18273
+ 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.',
18274
+ 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\`.`,
18275
+ 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.',
18276
+ 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`.',
18277
+ },
18278
+ };
18279
+ /** Default Qdrant URL for watcher dependency check. */
18280
+ const QDRANT_URL = 'http://127.0.0.1:6333';
18281
+ /** Health probe timeout in milliseconds. */
18282
+ const PROBE_TIMEOUT_MS = 3000;
18427
18283
  /**
18428
- * Create a ComponentWriter for a validated component descriptor.
18284
+ * Check if Qdrant is reachable (watcher dependency).
18429
18285
  *
18430
- * @remarks
18431
- * The descriptor is validated via the Zod schema at runtime.
18432
- * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
18286
+ * @returns True if Qdrant responds.
18287
+ */
18288
+ async function isQdrantAvailable() {
18289
+ try {
18290
+ await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS);
18291
+ return true;
18292
+ }
18293
+ catch {
18294
+ return false;
18295
+ }
18296
+ }
18297
+ /**
18298
+ * Determine the state of a single component.
18433
18299
  *
18434
- * @param descriptor - The component descriptor to validate and wrap.
18435
- * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
18436
- * @returns A new `ComponentWriter` instance.
18437
- * @throws ZodError if the descriptor is invalid.
18300
+ * @param name - Component name.
18301
+ * @param registry - Current component-versions.json contents.
18302
+ * @param configRoot - Config root path.
18303
+ * @param healthySet - Set of component names known to be healthy (for dep checks).
18304
+ * @returns The component's state.
18438
18305
  */
18439
- function createComponentWriter(descriptor, options) {
18440
- // Validate via Zod throws ZodError with detailed messages on failure
18441
- jeevesComponentDescriptorSchema.parse(descriptor);
18442
- return new ComponentWriter(descriptor, options);
18306
+ async function determineComponentState(name, registry, configRoot, healthySet) {
18307
+ // Not in registry = not installed
18308
+ if (!(name in registry))
18309
+ return 'not_installed';
18310
+ // Check hard dependencies
18311
+ const deps = COMPONENT_DEPS[name];
18312
+ for (const hardDep of deps.hard) {
18313
+ if (!healthySet.has(hardDep))
18314
+ return 'deps_missing';
18315
+ }
18316
+ // Watcher-specific: check Qdrant
18317
+ if (name === 'watcher' && !(await isQdrantAvailable())) {
18318
+ return 'deps_missing';
18319
+ }
18320
+ // Check config file
18321
+ const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
18322
+ if (!existsSync(configPath))
18323
+ return 'config_missing';
18324
+ // Fast path: probe HTTP health endpoint
18325
+ try {
18326
+ const url = getServiceUrl$1(name);
18327
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
18328
+ // Healthy — check for available updates
18329
+ const entry = registry[name];
18330
+ if (entry.pluginPackage && entry.pluginVersion) {
18331
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
18332
+ const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
18333
+ if (latestVersion && semverExports.gt(latestVersion, entry.pluginVersion)) {
18334
+ return 'update_available';
18335
+ }
18336
+ }
18337
+ return 'healthy';
18338
+ }
18339
+ catch {
18340
+ // Service not responding — classify sub-state
18341
+ const serviceState = getServiceState(toServiceName(name));
18342
+ if (serviceState === 'not_installed')
18343
+ return 'service_not_installed';
18344
+ if (serviceState === 'stopped')
18345
+ return 'service_stopped';
18346
+ // serviceState === 'running' but HTTP failed — still treat as stopped
18347
+ return 'service_stopped';
18348
+ }
18443
18349
  }
18444
-
18445
18350
  /**
18446
- * Tool result formatters for the OpenClaw plugin SDK.
18351
+ * Generate the alert text for a component in a given state.
18447
18352
  *
18448
- * @remarks
18449
- * Provides standardised helpers for building `ToolResult` objects:
18450
- * success, error, and connection-error variants.
18353
+ * @param name - Component name.
18354
+ * @param state - The component's state.
18355
+ * @param configRoot - Config root path.
18356
+ * @returns Alert text (list items), or empty string if healthy.
18451
18357
  */
18358
+ function generateAlertText(name, state, configRoot, registry) {
18359
+ if (state === 'healthy')
18360
+ return '';
18361
+ // Update available — dynamic text with version info
18362
+ if (state === 'update_available') {
18363
+ const entry = registry[name];
18364
+ const currentVersion = entry.pluginVersion ?? 'unknown';
18365
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
18366
+ const latestVersion = entry.pluginPackage
18367
+ ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
18368
+ 'unknown')
18369
+ : 'unknown';
18370
+ const installCmd = entry.pluginPackage
18371
+ ? `\`npx ${entry.pluginPackage} install\``
18372
+ : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
18373
+ return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
18374
+ }
18375
+ const componentAlerts = ALERT_TEXT[name];
18376
+ const alertOrFn = componentAlerts[state];
18377
+ if (!alertOrFn)
18378
+ return '';
18379
+ const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
18380
+ return `- ${text}`;
18381
+ }
18452
18382
  /**
18453
- * Format a successful tool result.
18383
+ * Orchestrate HEARTBEAT entries for all platform components.
18454
18384
  *
18455
- * @param data - Arbitrary data to return as JSON.
18456
- * @returns A `ToolResult` with JSON-stringified content.
18385
+ * @param options - Orchestration configuration.
18386
+ * @returns Array of HeartbeatEntry for writeHeartbeatSection.
18457
18387
  */
18458
- function ok(data) {
18459
- return {
18460
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
18461
- };
18388
+ async function orchestrateHeartbeat(options) {
18389
+ const { coreConfigDir, configRoot, declinedNames } = options;
18390
+ const registry = readComponentVersions(coreConfigDir);
18391
+ // First pass: determine which components are healthy (for dep resolution)
18392
+ const healthySet = new Set();
18393
+ for (const name of PLATFORM_COMPONENTS) {
18394
+ if (declinedNames.has(toServiceName(name)))
18395
+ continue;
18396
+ if (!(name in registry))
18397
+ continue;
18398
+ try {
18399
+ const url = getServiceUrl$1(name);
18400
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
18401
+ healthySet.add(name);
18402
+ }
18403
+ catch {
18404
+ // Not healthy — will be classified in second pass
18405
+ }
18406
+ }
18407
+ // Second pass: generate entries
18408
+ const entries = [];
18409
+ for (const name of PLATFORM_COMPONENTS) {
18410
+ const fullName = toServiceName(name);
18411
+ // Declined
18412
+ if (declinedNames.has(fullName)) {
18413
+ // Auto-decline dependents of declined hard deps
18414
+ entries.push({ name: fullName, declined: true, content: '' });
18415
+ continue;
18416
+ }
18417
+ const state = await determineComponentState(name, registry, configRoot, healthySet);
18418
+ // Auto-decline if hard dep is declined
18419
+ const deps = COMPONENT_DEPS[name];
18420
+ const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
18421
+ if (hardDepDeclined) {
18422
+ entries.push({ name: fullName, declined: true, content: '' });
18423
+ continue;
18424
+ }
18425
+ const alertText = generateAlertText(name, state, configRoot, registry);
18426
+ entries.push({ name: fullName, declined: false, content: alertText });
18427
+ }
18428
+ // Add soft-dep informational alerts for any healthy component with soft deps
18429
+ for (const entry of entries) {
18430
+ if (entry.declined || entry.content)
18431
+ continue;
18432
+ // Entry is healthy (no alert, not declined) — check for soft deps
18433
+ const shortName = entry.name.replace(/^jeeves-/, '');
18434
+ const deps = COMPONENT_DEPS[shortName];
18435
+ if (!deps.soft.length)
18436
+ continue;
18437
+ const softAlerts = [];
18438
+ for (const dep of deps.soft) {
18439
+ const depFullName = toServiceName(dep);
18440
+ if (declinedNames.has(depFullName))
18441
+ continue;
18442
+ if (!healthySet.has(dep)) {
18443
+ softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
18444
+ }
18445
+ }
18446
+ if (softAlerts.length > 0) {
18447
+ entry.content = softAlerts.join('\n');
18448
+ }
18449
+ }
18450
+ return entries;
18462
18451
  }
18452
+
18463
18453
  /**
18464
- * Format an error tool result.
18454
+ * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
18465
18455
  *
18466
- * @param error - Error instance, string, or other value.
18467
- * @returns A `ToolResult` with `isError: true`.
18456
+ * @remarks
18457
+ * Reads existing HEARTBEAT.md, resolves declined components, runs the
18458
+ * heartbeat state machine, and writes the result. Best-effort: failures
18459
+ * are logged but do not propagate.
18468
18460
  */
18469
- function fail(error) {
18470
- const message = error instanceof Error ? error.message : String(error);
18471
- return {
18472
- content: [{ type: 'text', text: 'Error: ' + message }],
18473
- isError: true,
18474
- };
18475
- }
18476
18461
  /**
18477
- * Format a connection error with actionable guidance.
18478
- *
18479
- * @remarks
18480
- * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
18481
- * `error.cause.code` and returns a user-friendly message referencing
18482
- * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
18483
- * non-connection errors.
18462
+ * Read a file's content, returning empty string if the file does not exist.
18484
18463
  *
18485
- * @param error - Error instance (typically from `fetch`).
18486
- * @param baseUrl - The URL that was being contacted.
18487
- * @param pluginId - The plugin identifier for config guidance.
18488
- * @returns A `ToolResult` with `isError: true`.
18464
+ * @param filePath - Absolute file path.
18465
+ * @returns File content or empty string.
18489
18466
  */
18490
- function connectionFail(error, baseUrl, pluginId) {
18491
- const cause = error instanceof Error ? error.cause : undefined;
18492
- const code = cause && typeof cause === 'object' && 'code' in cause
18493
- ? String(cause.code)
18494
- : '';
18495
- const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
18496
- if (isConnectionError) {
18497
- return {
18498
- content: [
18499
- {
18500
- type: 'text',
18501
- text: [
18502
- `Service not reachable at ${baseUrl}.`,
18503
- 'Either start the service, or if it runs on a different port,',
18504
- `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
18505
- ].join('\n'),
18506
- },
18507
- ],
18508
- isError: true,
18509
- };
18467
+ function readFileOrEmpty(filePath) {
18468
+ try {
18469
+ return readFileSync(filePath, 'utf-8');
18470
+ }
18471
+ catch (err) {
18472
+ if (err instanceof Error &&
18473
+ 'code' in err &&
18474
+ err.code === 'ENOENT') {
18475
+ return '';
18476
+ }
18477
+ throw err;
18510
18478
  }
18511
- return fail(error);
18512
18479
  }
18513
-
18514
- /**
18515
- * Factory for the standard plugin tool set.
18516
- *
18517
- * @remarks
18518
- * Produces four standard tools from a component descriptor:
18519
- * - `{name}_status` - Probe service health + version + uptime
18520
- * - `{name}_config` - Query running config with optional JSONPath
18521
- * - `{name}_config_apply` - Push config patch to running service
18522
- * - `{name}_service` - Service lifecycle management
18523
- *
18524
- * Components add domain-specific tools separately.
18525
- */
18526
- /** Timeout for HTTP probes in milliseconds. */
18527
- const PROBE_TIMEOUT_MS = 5000;
18528
18480
  /**
18529
- * Create the standard plugin tool set from a component descriptor.
18481
+ * Run a single HEARTBEAT orchestration cycle.
18530
18482
  *
18531
- * @param descriptor - The component descriptor.
18532
- * @returns Array of tool descriptors to register.
18483
+ * @param options - Heartbeat cycle configuration.
18533
18484
  */
18534
- function createPluginToolset(descriptor) {
18535
- const { name, defaultPort } = descriptor;
18536
- const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
18537
- const svcManager = createServiceManager(descriptor);
18538
- const statusTool = {
18539
- name: `${name}_status`,
18540
- description: `Get ${name} service health, version, and uptime.`,
18541
- parameters: {
18542
- type: 'object',
18543
- properties: {},
18544
- },
18545
- execute: async () => {
18546
- try {
18547
- const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS);
18548
- if (!res.ok) {
18549
- return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
18550
- }
18551
- const data = await res.json();
18552
- return ok(data);
18553
- }
18554
- catch (err) {
18555
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18556
- }
18557
- },
18558
- };
18559
- const configTool = {
18560
- name: `${name}_config`,
18561
- description: `Query ${name} running configuration. Optional JSONPath filter.`,
18562
- parameters: {
18563
- type: 'object',
18564
- properties: {
18565
- path: {
18566
- type: 'string',
18567
- description: 'JSONPath expression (optional)',
18568
- },
18569
- },
18570
- },
18571
- execute: async (_id, params) => {
18572
- const path = params.path;
18573
- const qs = path ? `?path=${encodeURIComponent(path)}` : '';
18574
- try {
18575
- const result = await fetchJson(`${baseUrl}/config${qs}`);
18576
- return ok(result);
18577
- }
18578
- catch (err) {
18579
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18580
- }
18581
- },
18582
- };
18583
- const configApplyTool = {
18584
- name: `${name}_config_apply`,
18585
- description: `Apply a config patch to the running ${name} service.`,
18586
- parameters: {
18587
- type: 'object',
18588
- properties: {
18589
- config: {
18590
- type: 'object',
18591
- description: 'Config patch to apply',
18592
- },
18593
- },
18594
- required: ['config'],
18595
- },
18596
- execute: async (_id, params) => {
18597
- const config = params.config;
18598
- if (!config) {
18599
- return fail('Missing required parameter: config');
18600
- }
18601
- try {
18602
- const result = await postJson(`${baseUrl}/config/apply`, {
18603
- patch: config,
18604
- });
18605
- return ok(result);
18606
- }
18607
- catch (err) {
18608
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
18609
- }
18610
- },
18611
- };
18612
- const serviceTool = {
18613
- name: `${name}_service`,
18614
- description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
18615
- parameters: {
18616
- type: 'object',
18617
- properties: {
18618
- action: {
18619
- type: 'string',
18620
- enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
18621
- description: 'Service action to perform',
18622
- },
18623
- },
18624
- required: ['action'],
18625
- },
18626
- execute: (_id, params) => {
18627
- const action = params.action;
18628
- const validActions = [
18629
- 'install',
18630
- 'uninstall',
18631
- 'start',
18632
- 'stop',
18633
- 'restart',
18634
- 'status',
18635
- ];
18636
- if (!validActions.includes(action)) {
18637
- return Promise.resolve(fail(`Invalid action: ${action}`));
18638
- }
18639
- try {
18640
- if (action === 'status') {
18641
- const state = svcManager.status();
18642
- return Promise.resolve(ok({ service: name, state }));
18643
- }
18644
- // Call the appropriate method
18645
- const methodMap = {
18646
- install: () => {
18647
- svcManager.install();
18648
- },
18649
- uninstall: () => {
18650
- svcManager.uninstall();
18651
- },
18652
- start: () => {
18653
- svcManager.start();
18654
- },
18655
- stop: () => {
18656
- svcManager.stop();
18657
- },
18658
- restart: () => {
18659
- svcManager.restart();
18660
- },
18661
- };
18662
- methodMap[action]();
18663
- return Promise.resolve(ok({ service: name, action, success: true }));
18485
+ async function runHeartbeatCycle(options) {
18486
+ const { workspacePath, coreConfigDir, configRoot } = options;
18487
+ const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
18488
+ try {
18489
+ const existingContent = readFileOrEmpty(heartbeatPath);
18490
+ const parsed = parseHeartbeat(existingContent);
18491
+ const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
18492
+ const entries = await orchestrateHeartbeat({
18493
+ coreConfigDir,
18494
+ configRoot,
18495
+ declinedNames,
18496
+ });
18497
+ // Memory hygiene check (Decision 49)
18498
+ if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
18499
+ const wsConfig = loadWorkspaceConfig(workspacePath);
18500
+ const memoryEntry = checkMemoryHealth({
18501
+ workspacePath,
18502
+ budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
18503
+ warningThreshold: wsConfig?.memory?.warningThreshold ??
18504
+ WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
18505
+ staleDays: wsConfig?.memory?.staleDays ??
18506
+ WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
18507
+ });
18508
+ if (memoryEntry)
18509
+ entries.push(memoryEntry);
18510
+ }
18511
+ else {
18512
+ entries.push({
18513
+ name: MEMORY_HEARTBEAT_NAME,
18514
+ declined: true,
18515
+ content: '',
18516
+ });
18517
+ }
18518
+ // Workspace file size health check (Decision 70)
18519
+ const wsFileResults = checkWorkspaceFileHealth({ workspacePath });
18520
+ const wsFileAlerts = workspaceFileHealthEntries(wsFileResults);
18521
+ for (const alert of wsFileAlerts) {
18522
+ if (declinedNames.has(alert.name)) {
18523
+ entries.push({ name: alert.name, declined: true, content: '' });
18664
18524
  }
18665
- catch (err) {
18666
- return Promise.resolve(fail(`Service ${action} failed: ${getErrorMessage(err)}`));
18525
+ else {
18526
+ entries.push(alert);
18667
18527
  }
18668
- },
18669
- };
18670
- return [statusTool, configTool, configApplyTool, serviceTool];
18528
+ }
18529
+ await writeHeartbeatSection(heartbeatPath, entries);
18530
+ }
18531
+ catch (err) {
18532
+ console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${getErrorMessage(err)}`);
18533
+ }
18671
18534
  }
18672
18535
 
18673
18536
  /**
18674
- * Plugin resolution helpers for the OpenClaw plugin SDK.
18537
+ * Timer-based orchestrator for managed content writing.
18675
18538
  *
18676
18539
  * @remarks
18677
- * Provides workspace path resolution and plugin setting resolution
18678
- * with a standard three-step fallback chain:
18679
- * plugin config environment variable → default value.
18540
+ * `ComponentWriter` manages a component's TOOLS.md section writes
18541
+ * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
18542
+ * on a configurable prime-interval timer cycle.
18680
18543
  */
18681
18544
  /**
18682
- * Resolve the workspace root from the OpenClaw plugin API.
18545
+ * Orchestrates managed content writing for a single Jeeves component.
18683
18546
  *
18684
18547
  * @remarks
18685
- * Tries three sources in order:
18686
- * 1. `api.config.agents.defaults.workspace` explicit config
18687
- * 2. `api.resolvePath('.')` gateway-provided path resolver
18688
- * 3. `process.cwd()` — last resort
18689
- *
18690
- * @param api - The plugin API object provided by the gateway.
18691
- * @returns Absolute path to the workspace root.
18548
+ * Created via {@link createComponentWriter}. Manages a timer that fires
18549
+ * at the component's prime-interval, calling `generateToolsContent()`
18550
+ * and `refreshPlatformContent()` on each cycle.
18692
18551
  */
18693
- function resolveWorkspacePath(api) {
18694
- const configured = api.config?.agents?.defaults?.workspace;
18695
- if (typeof configured === 'string' && configured.trim()) {
18696
- return configured;
18552
+ class ComponentWriter {
18553
+ timer;
18554
+ jitterTimeout;
18555
+ component;
18556
+ configDir;
18557
+ gatewayUrl;
18558
+ pendingCleanups = new Set();
18559
+ /** @internal */
18560
+ constructor(component, options) {
18561
+ this.component = component;
18562
+ this.configDir = getComponentConfigDir(component.name);
18563
+ this.gatewayUrl = options?.gatewayUrl;
18697
18564
  }
18698
- if (typeof api.resolvePath === 'function') {
18699
- return api.resolvePath('.');
18565
+ /** The component's config directory path. */
18566
+ get componentConfigDir() {
18567
+ return this.configDir;
18568
+ }
18569
+ /** Whether the writer timer is currently running or pending its first cycle. */
18570
+ get isRunning() {
18571
+ return this.jitterTimeout !== undefined || this.timer !== undefined;
18572
+ }
18573
+ /**
18574
+ * Start the writer timer.
18575
+ *
18576
+ * @remarks
18577
+ * Delays the first cycle by a random jitter (0 to one full interval) to
18578
+ * spread initial writes across all component plugins and reduce EPERM
18579
+ * contention on startup.
18580
+ */
18581
+ start() {
18582
+ if (this.isRunning)
18583
+ return;
18584
+ // Random jitter up to one full interval to spread initial writes
18585
+ const intervalMs = this.component.refreshIntervalSeconds * 1000;
18586
+ const jitterMs = Math.floor(Math.random() * intervalMs);
18587
+ this.jitterTimeout = setTimeout(() => {
18588
+ this.jitterTimeout = undefined;
18589
+ void this.cycle();
18590
+ this.timer = setInterval(() => void this.cycle(), intervalMs);
18591
+ }, jitterMs);
18592
+ }
18593
+ /** Stop the writer timer. */
18594
+ stop() {
18595
+ if (this.jitterTimeout) {
18596
+ clearTimeout(this.jitterTimeout);
18597
+ this.jitterTimeout = undefined;
18598
+ }
18599
+ if (this.timer) {
18600
+ clearInterval(this.timer);
18601
+ this.timer = undefined;
18602
+ }
18603
+ }
18604
+ /**
18605
+ * Execute a single write cycle.
18606
+ *
18607
+ * @remarks
18608
+ * 1. Write the component's TOOLS.md section.
18609
+ * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
18610
+ * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
18611
+ * 4. Run HEARTBEAT health orchestration.
18612
+ */
18613
+ async cycle() {
18614
+ try {
18615
+ const workspacePath = getWorkspacePath();
18616
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
18617
+ // 1. Write the component's TOOLS.md section
18618
+ const toolsContent = this.component.generateToolsContent();
18619
+ await updateManagedSection(toolsPath, toolsContent, {
18620
+ mode: 'section',
18621
+ sectionId: this.component.sectionId,
18622
+ markers: TOOLS_MARKERS,
18623
+ coreVersion: CORE_VERSION,
18624
+ });
18625
+ // 2. Platform content maintenance
18626
+ await refreshPlatformContent({
18627
+ coreVersion: CORE_VERSION,
18628
+ componentName: this.component.name,
18629
+ componentVersion: this.component.version,
18630
+ servicePackage: this.component.servicePackage,
18631
+ pluginPackage: this.component.pluginPackage,
18632
+ });
18633
+ // 3. Cleanup escalation
18634
+ if (this.gatewayUrl) {
18635
+ scanAndEscalateCleanup([
18636
+ { filePath: toolsPath, markerIdentity: 'TOOLS' },
18637
+ {
18638
+ filePath: join(workspacePath, WORKSPACE_FILES.soul),
18639
+ markerIdentity: 'SOUL',
18640
+ },
18641
+ {
18642
+ filePath: join(workspacePath, WORKSPACE_FILES.agents),
18643
+ markerIdentity: 'AGENTS',
18644
+ },
18645
+ ], this.gatewayUrl, this.pendingCleanups);
18646
+ }
18647
+ // 4. HEARTBEAT orchestration
18648
+ await runHeartbeatCycle({
18649
+ workspacePath,
18650
+ coreConfigDir: getCoreConfigDir(),
18651
+ configRoot: getConfigRoot$1(),
18652
+ });
18653
+ }
18654
+ catch (err) {
18655
+ console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${getErrorMessage(err)}`);
18656
+ }
18700
18657
  }
18701
- return process.cwd();
18702
18658
  }
18659
+
18703
18660
  /**
18704
- * Resolve a plugin setting via the standard three-step fallback chain:
18705
- * plugin config → environment variable → fallback value.
18661
+ * Creates a synchronous content accessor backed by an async data source.
18706
18662
  *
18707
- * @param api - Plugin API object.
18708
- * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
18709
- * @param key - Config key within the plugin's config object.
18710
- * @param envVar - Environment variable name.
18711
- * @param fallback - Default value if neither source provides one.
18712
- * @returns The resolved setting value.
18663
+ * @remarks
18664
+ * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
18665
+ * the interface is synchronous, but most components fetch live data from
18666
+ * their HTTP service. This utility returns a sync `() => string` that
18667
+ * serves the last successfully fetched value while kicking off a background
18668
+ * refresh on each call.
18669
+ *
18670
+ * First call returns `placeholder`. Subsequent calls return the last
18671
+ * successfully fetched content. If a refresh fails, the previous good
18672
+ * value is retained.
18673
+ *
18674
+ * @example
18675
+ * ```typescript
18676
+ * const getContent = createAsyncContentCache({
18677
+ * fetch: async () => {
18678
+ * const res = await fetch('http://127.0.0.1:1936/status');
18679
+ * return formatWatcherStatus(await res.json());
18680
+ * },
18681
+ * placeholder: '> Initializing watcher status...',
18682
+ * });
18683
+ *
18684
+ * const writer = createComponentWriter({
18685
+ * // ...
18686
+ * generateToolsContent: getContent,
18687
+ * });
18688
+ * ```
18713
18689
  */
18714
- function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
18715
- const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
18716
- if (typeof fromPlugin === 'string')
18717
- return fromPlugin;
18718
- const fromEnv = process.env[envVar];
18719
- if (fromEnv)
18720
- return fromEnv;
18721
- return fallback;
18690
+ /**
18691
+ * Creates a synchronous content accessor backed by an async data source.
18692
+ *
18693
+ * @param options - Cache configuration.
18694
+ * @returns A sync `() => string` suitable for `generateToolsContent`.
18695
+ */
18696
+ function createAsyncContentCache(options) {
18697
+ const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
18698
+ console.warn('[jeeves] async content cache refresh failed:', err);
18699
+ }, } = options;
18700
+ let cached = placeholder;
18701
+ let refreshing = false;
18702
+ return () => {
18703
+ if (!refreshing) {
18704
+ refreshing = true;
18705
+ fetchContent()
18706
+ .then((content) => {
18707
+ cached = content;
18708
+ })
18709
+ .catch(onError)
18710
+ .finally(() => {
18711
+ refreshing = false;
18712
+ });
18713
+ }
18714
+ return cached;
18715
+ };
18722
18716
  }
18717
+
18723
18718
  /**
18724
- * Resolve an optional plugin setting via the two-step fallback chain:
18725
- * plugin config → environment variable. Returns `undefined` if neither
18726
- * source provides a value.
18719
+ * Factory function for creating a ComponentWriter from a descriptor.
18727
18720
  *
18728
- * @param api - Plugin API object.
18729
- * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
18730
- * @param key - Config key within the plugin's config object.
18731
- * @param envVar - Environment variable name.
18732
- * @returns The resolved setting value, or `undefined`.
18721
+ * @remarks
18722
+ * Validates the descriptor via Zod schema and creates a ComponentWriter.
18723
+ * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
18724
+ * `JeevesComponent` interface is no longer accepted.
18733
18725
  */
18734
- function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
18735
- const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
18736
- if (typeof fromPlugin === 'string')
18737
- return fromPlugin;
18738
- const fromEnv = process.env[envVar];
18739
- if (fromEnv)
18740
- return fromEnv;
18741
- return undefined;
18726
+ /**
18727
+ * Create a ComponentWriter for a validated component descriptor.
18728
+ *
18729
+ * @remarks
18730
+ * The descriptor is validated via the Zod schema at runtime.
18731
+ * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
18732
+ *
18733
+ * @param descriptor - The component descriptor to validate and wrap.
18734
+ * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
18735
+ * @returns A new `ComponentWriter` instance.
18736
+ * @throws ZodError if the descriptor is invalid.
18737
+ */
18738
+ function createComponentWriter(descriptor, options) {
18739
+ // Validate via Zod — throws ZodError with detailed messages on failure
18740
+ jeevesComponentDescriptorSchema.parse(descriptor);
18741
+ return new ComponentWriter(descriptor, options);
18742
18742
  }
18743
18743
 
18744
18744
  /**