@karmaniverous/jeeves 0.5.3 → 0.5.4

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
@@ -6,10 +6,10 @@ import { JSONPath } from 'jsonpath-plus';
6
6
  import { major, valid, gte, gt } from 'semver';
7
7
  import { z } from 'zod';
8
8
  import * as commander from 'commander';
9
- import { packageDirectorySync } from 'package-directory';
10
- import { homedir } from 'node:os';
11
9
  import cp, { execSync } from 'node:child_process';
10
+ import { homedir } from 'node:os';
12
11
  import { fileURLToPath } from 'node:url';
12
+ import { packageDirectorySync } from 'package-directory';
13
13
 
14
14
  /**
15
15
  * Comment markers for managed content blocks.
@@ -183,14 +183,14 @@ const PLATFORM_COMPONENTS = [
183
183
  * Core library version, inlined at build time.
184
184
  *
185
185
  * @remarks
186
- * The `0.5.2` placeholder is replaced by
186
+ * The `0.5.3` placeholder is replaced by
187
187
  * `@rollup/plugin-replace` during the build with the actual version
188
188
  * from `package.json`. This ensures the correct version survives
189
189
  * when consumers bundle core into their own dist (where runtime
190
190
  * `import.meta.url`-based resolution would find the wrong package.json).
191
191
  */
192
192
  /** The core library version from package.json (inlined at build time). */
193
- const CORE_VERSION = '0.5.2';
193
+ const CORE_VERSION = '0.5.3';
194
194
 
195
195
  /**
196
196
  * Workspace and config root initialization.
@@ -1566,969 +1566,1319 @@ function seedSkill(workspacePath) {
1566
1566
  }
1567
1567
 
1568
1568
  /**
1569
- * OpenClaw configuration helpers for plugin CLI installers.
1569
+ * Zod schema for the Jeeves component descriptor.
1570
1570
  *
1571
1571
  * @remarks
1572
- * Provides resolution of OpenClaw home directory and config file path,
1573
- * plus idempotent config patching for plugin install/uninstall.
1572
+ * The descriptor replaces the v0.4.0 `JeevesComponent` interface with a
1573
+ * Zod-first approach. The TypeScript type is inferred via `z.infer<>`.
1574
+ * Validates at parse time: prime interval, callable functions.
1574
1575
  */
1575
1576
  /**
1576
- * Resolve the OpenClaw home directory.
1577
+ * Check whether a number is prime.
1578
+ *
1579
+ * @param n - Number to check.
1580
+ * @returns `true` if n is prime.
1581
+ */
1582
+ function isPrime(n) {
1583
+ if (n < 2)
1584
+ return false;
1585
+ if (n === 2)
1586
+ return true;
1587
+ if (n % 2 === 0)
1588
+ return false;
1589
+ for (let i = 3; i * i <= n; i += 2) {
1590
+ if (n % i === 0)
1591
+ return false;
1592
+ }
1593
+ return true;
1594
+ }
1595
+ /**
1596
+ * Zod schema for the Jeeves component descriptor.
1577
1597
  *
1578
1598
  * @remarks
1579
- * Resolution order:
1580
- * 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
1581
- * 2. `OPENCLAW_HOME` env var → resolved path
1582
- * 3. Default: `~/.openclaw`
1599
+ * Single source of truth for what a component must provide.
1600
+ * Factories consume this descriptor to produce CLI commands,
1601
+ * plugin tools, and HTTP handlers.
1602
+ */
1603
+ const jeevesComponentDescriptorSchema = z.object({
1604
+ /** Component name (e.g., 'watcher', 'runner', 'server', 'meta'). */
1605
+ name: z.string().min(1, 'name must be a non-empty string'),
1606
+ /** Component version (from package.json). */
1607
+ version: z.string().min(1, 'version must be a non-empty string'),
1608
+ /** npm package name for the service. */
1609
+ servicePackage: z.string().min(1),
1610
+ /** npm package name for the plugin. */
1611
+ pluginPackage: z.string().min(1),
1612
+ /** System service name. Defaults to `jeeves-${name}` when not provided. */
1613
+ serviceName: z.string().min(1).optional(),
1614
+ /** Default port for the service's HTTP API. */
1615
+ defaultPort: z.number().int().positive(),
1616
+ /** Zod schema for validating config files. */
1617
+ configSchema: z.custom((val) => val !== null &&
1618
+ typeof val === 'object' &&
1619
+ typeof val.parse === 'function', { message: 'configSchema must be a Zod schema' }),
1620
+ /** Config file name (e.g., 'jeeves-watcher.config.json'). */
1621
+ configFileName: z.string().min(1),
1622
+ /** Returns a default config object for `init`. */
1623
+ initTemplate: z.function({
1624
+ input: [],
1625
+ output: z.record(z.string(), z.unknown()),
1626
+ }),
1627
+ /**
1628
+ * Service-side callback after config apply. Receives the merged,
1629
+ * validated config (not the raw patch). Optional — if omitted,
1630
+ * write-only (service picks up changes on restart).
1631
+ */
1632
+ onConfigApply: z
1633
+ .function({
1634
+ input: [z.record(z.string(), z.unknown())],
1635
+ output: z.promise(z.void()),
1636
+ })
1637
+ .optional(),
1638
+ /**
1639
+ * Custom merge function for config apply. Receives the existing config
1640
+ * and the patch, returns the merged result. Optional — if omitted,
1641
+ * the default deep-merge (object-recursive, array-replacing) is used.
1642
+ *
1643
+ * Use this to implement domain-specific merge strategies such as
1644
+ * name-based array merging for inference rules.
1645
+ */
1646
+ customMerge: z
1647
+ .function({
1648
+ input: [
1649
+ z.record(z.string(), z.unknown()),
1650
+ z.record(z.string(), z.unknown()),
1651
+ ],
1652
+ output: z.record(z.string(), z.unknown()),
1653
+ })
1654
+ .optional(),
1655
+ /**
1656
+ * Returns command + args for launching the service process.
1657
+ * Consumed by `service install`.
1658
+ */
1659
+ startCommand: z.function({
1660
+ input: [z.string()],
1661
+ output: z.array(z.string()),
1662
+ }),
1663
+ /** In-process service entry point for the CLI `start` command. */
1664
+ run: z.function({
1665
+ input: [z.string()],
1666
+ output: z.promise(z.void()),
1667
+ }),
1668
+ /** TOOLS.md section name (e.g., 'Watcher'). */
1669
+ sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
1670
+ /** Refresh interval in seconds (must be a prime number). */
1671
+ refreshIntervalSeconds: z.number().int().positive().refine(isPrime, {
1672
+ message: 'refreshIntervalSeconds must be a prime number',
1673
+ }),
1674
+ /** Produce the component's TOOLS.md section content. */
1675
+ generateToolsContent: z.function({ input: [], output: z.string() }),
1676
+ /** Component dependencies for HEARTBEAT alert suppression. */
1677
+ dependencies: z
1678
+ .object({
1679
+ /** Components that must be healthy for this component to function. */
1680
+ hard: z.array(z.string()),
1681
+ /** Components that improve behavior but are not strictly required. */
1682
+ soft: z.array(z.string()),
1683
+ })
1684
+ .optional(),
1685
+ /** Extension point: add custom CLI commands to the service CLI. */
1686
+ customCliCommands: z
1687
+ .function({ input: [z.custom()], output: z.void() })
1688
+ .optional(),
1689
+ /** Extension point: return additional plugin tool descriptors. */
1690
+ customPluginTools: z
1691
+ .function({ input: [z.custom()], output: z.array(z.unknown()) })
1692
+ .optional(),
1693
+ });
1694
+ /**
1695
+ * Derive the effective service name from a descriptor.
1583
1696
  *
1584
- * @returns Absolute path to the OpenClaw home directory.
1697
+ * @param descriptor - The component descriptor.
1698
+ * @returns The service name (explicit or derived from `jeeves-{name}`).
1585
1699
  */
1586
- function resolveOpenClawHome() {
1587
- if (process.env.OPENCLAW_CONFIG) {
1588
- return dirname(resolve(process.env.OPENCLAW_CONFIG));
1589
- }
1590
- if (process.env.OPENCLAW_HOME) {
1591
- return resolve(process.env.OPENCLAW_HOME);
1592
- }
1593
- return join(homedir(), '.openclaw');
1700
+ function getEffectiveServiceName(descriptor) {
1701
+ return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
1594
1702
  }
1703
+
1595
1704
  /**
1596
- * Resolve the OpenClaw config file path.
1705
+ * Platform-aware service state detection.
1597
1706
  *
1598
1707
  * @remarks
1599
- * If `OPENCLAW_CONFIG` is set, uses that directly.
1600
- * Otherwise defaults to `{home}/openclaw.json`.
1708
+ * Detects whether a system service is installed and running.
1709
+ * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
1710
+ */
1711
+ /**
1712
+ * Detect the state of a system service by name.
1601
1713
  *
1602
- * @param home - The OpenClaw home directory.
1603
- * @returns Absolute path to the config file.
1714
+ * @param serviceName - The service name (e.g., 'jeeves-runner').
1715
+ * @returns The detected service state.
1604
1716
  */
1605
- function resolveConfigPath(home) {
1606
- if (process.env.OPENCLAW_CONFIG) {
1607
- return resolve(process.env.OPENCLAW_CONFIG);
1717
+ function getServiceState(serviceName) {
1718
+ switch (process.platform) {
1719
+ case 'win32':
1720
+ return getServiceStateWindows(serviceName);
1721
+ case 'darwin':
1722
+ return getServiceStateMacOS(serviceName);
1723
+ default:
1724
+ return getServiceStateLinux(serviceName);
1608
1725
  }
1609
- return join(home, 'openclaw.json');
1610
1726
  }
1611
1727
  /**
1612
- * Patch an allowlist array: add or remove the plugin ID.
1613
- *
1614
- * @returns A log message if a change was made, or undefined.
1728
+ * Windows: detect via NSSM.
1729
+ * - Exit code 3 = service does not exist
1730
+ * - "SERVICE_RUNNING" in output = running
1731
+ * - Other output = stopped/paused
1615
1732
  */
1616
- function patchAllowList(parent, key, label, pluginId, mode) {
1617
- if (mode === 'add') {
1618
- if (!Array.isArray(parent[key])) {
1619
- parent[key] = [pluginId];
1620
- return `Created ${label} with "${pluginId}"`;
1621
- }
1622
- const list = parent[key];
1623
- if (!list.includes(pluginId)) {
1624
- list.push(pluginId);
1625
- return `Added "${pluginId}" to ${label}`;
1626
- }
1733
+ function getServiceStateWindows(serviceName) {
1734
+ try {
1735
+ const output = execSync(`nssm status ${serviceName}`, {
1736
+ encoding: 'utf-8',
1737
+ timeout: 5000,
1738
+ stdio: ['pipe', 'pipe', 'pipe'],
1739
+ }).trim();
1740
+ if (output.includes('SERVICE_RUNNING'))
1741
+ return 'running';
1742
+ return 'stopped';
1627
1743
  }
1628
- else {
1629
- if (!Array.isArray(parent[key]))
1630
- return undefined;
1631
- const list = parent[key];
1632
- const filtered = list.filter((id) => id !== pluginId);
1633
- if (filtered.length !== list.length) {
1634
- parent[key] = filtered;
1635
- return `Removed "${pluginId}" from ${label}`;
1636
- }
1744
+ catch (err) {
1745
+ // NSSM exits with code 3 when the service doesn't exist
1746
+ if (isExecError(err) && err.status === 3)
1747
+ return 'not_installed';
1748
+ // Any other error (nssm not found, timeout, etc.) — treat as not installed
1749
+ return 'not_installed';
1637
1750
  }
1638
- return undefined;
1639
1751
  }
1640
1752
  /**
1641
- * Patch an OpenClaw config for plugin install or uninstall.
1642
- *
1643
- * @remarks
1644
- * Manages `plugins.entries.{pluginId}`, `plugins.installs.{pluginId}`,
1645
- * and `tools.alsoAllow`.
1646
- * Idempotent: adding twice produces no duplicates; removing when absent
1647
- * produces no errors.
1648
- *
1649
- * @param config - The parsed OpenClaw config object (mutated in place).
1650
- * @param pluginId - The plugin identifier.
1651
- * @param mode - Whether to add or remove the plugin.
1652
- * @param installRecord - Install provenance record (required when mode is 'add').
1653
- * @returns Array of log messages describing changes made.
1753
+ * Linux: detect via systemd user services.
1754
+ * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
1755
+ * - `systemctl --user is-active {name}.service` returns "active" = running
1654
1756
  */
1655
- function patchConfig(config, pluginId, mode, installRecord) {
1656
- const messages = [];
1657
- // Ensure plugins section
1658
- if (!config.plugins || typeof config.plugins !== 'object') {
1659
- config.plugins = {};
1757
+ function getServiceStateLinux(serviceName) {
1758
+ try {
1759
+ execSync(`systemctl --user is-enabled ${serviceName}.service`, {
1760
+ encoding: 'utf-8',
1761
+ timeout: 5000,
1762
+ stdio: ['pipe', 'pipe', 'pipe'],
1763
+ });
1660
1764
  }
1661
- const plugins = config.plugins;
1662
- // plugins.entries
1663
- if (!plugins.entries || typeof plugins.entries !== 'object') {
1664
- plugins.entries = {};
1765
+ catch {
1766
+ return 'not_installed';
1665
1767
  }
1666
- const entries = plugins.entries;
1667
- if (mode === 'add') {
1668
- if (!entries[pluginId]) {
1669
- entries[pluginId] = { enabled: true };
1670
- messages.push(`Added "${pluginId}" to plugins.entries`);
1671
- }
1672
- }
1673
- else if (pluginId in entries) {
1674
- Reflect.deleteProperty(entries, pluginId);
1675
- messages.push(`Removed "${pluginId}" from plugins.entries`);
1676
- }
1677
- // plugins.installs
1678
- if (!plugins.installs || typeof plugins.installs !== 'object') {
1679
- plugins.installs = {};
1680
- }
1681
- const installs = plugins.installs;
1682
- if (mode === 'add' && installRecord) {
1683
- installs[pluginId] = {
1684
- source: 'path',
1685
- installPath: installRecord.installPath,
1686
- version: installRecord.version,
1687
- installedAt: installRecord.installedAt ?? new Date().toISOString(),
1688
- };
1689
- messages.push(`Wrote install record for "${pluginId}" to plugins.installs`);
1690
- }
1691
- else if (mode === 'remove' && pluginId in installs) {
1692
- Reflect.deleteProperty(installs, pluginId);
1693
- messages.push(`Removed install record for "${pluginId}" from plugins.installs`);
1768
+ try {
1769
+ const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
1770
+ encoding: 'utf-8',
1771
+ timeout: 5000,
1772
+ stdio: ['pipe', 'pipe', 'pipe'],
1773
+ }).trim();
1774
+ if (output === 'active')
1775
+ return 'running';
1776
+ return 'stopped';
1694
1777
  }
1695
- // tools.alsoAllow
1696
- if (!config.tools || typeof config.tools !== 'object') {
1697
- config.tools = {};
1778
+ catch {
1779
+ return 'stopped';
1698
1780
  }
1699
- const tools = config.tools;
1700
- const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
1701
- if (toolAlsoAllow)
1702
- messages.push(toolAlsoAllow);
1703
- return messages;
1704
- }
1705
-
1706
- /**
1707
- * Internal helpers for the plugin installer CLI.
1708
- *
1709
- * @module
1710
- */
1711
- /**
1712
- * Derive a component name from a plugin ID.
1713
- *
1714
- * @remarks
1715
- * Strips `jeeves-` prefix and `-openclaw` suffix.
1716
- *
1717
- * @param pluginId - The plugin identifier.
1718
- * @returns Component short name.
1719
- */
1720
- function deriveComponentName(pluginId) {
1721
- return pluginId.replace(/^jeeves-/, '').replace(/-openclaw$/, '');
1722
1781
  }
1723
1782
  /**
1724
- * Copy all files from source directory to destination, recursively.
1725
- *
1726
- * @param srcDir - Source directory.
1727
- * @param destDir - Destination directory.
1783
+ * macOS: detect via launchctl.
1784
+ * - `launchctl list {name}` exits non-zero = not installed
1785
+ * - PID column is `-` or `0` = stopped
1728
1786
  */
1729
- function copyDistFiles(srcDir, destDir) {
1730
- mkdirSync(destDir, { recursive: true });
1731
- const entries = readdirSync(srcDir, { withFileTypes: true });
1732
- for (const entry of entries) {
1733
- const srcPath = join(srcDir, entry.name);
1734
- const destPath = join(destDir, entry.name);
1735
- if (entry.isDirectory()) {
1736
- copyDistFiles(srcPath, destPath);
1787
+ function getServiceStateMacOS(serviceName) {
1788
+ try {
1789
+ const output = execSync(`launchctl list ${serviceName}`, {
1790
+ encoding: 'utf-8',
1791
+ timeout: 5000,
1792
+ stdio: ['pipe', 'pipe', 'pipe'],
1793
+ }).trim();
1794
+ // launchctl list output formats:
1795
+ // 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
1796
+ // 2. Plist-style: '"PID" = 1234;'
1797
+ // 3. Single service: first token is the PID or "-"
1798
+ // Try table format: first token is PID
1799
+ const tableMatch = /^(\d+|-)\s/m.exec(output);
1800
+ if (tableMatch) {
1801
+ const pid = tableMatch[1];
1802
+ return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
1737
1803
  }
1738
- else {
1739
- copyFileSync(srcPath, destPath);
1804
+ // Try plist-style: "PID" = <number>;
1805
+ const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
1806
+ if (plistMatch) {
1807
+ return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
1740
1808
  }
1809
+ // If we got output but can't parse it, assume stopped (service exists but state unclear)
1810
+ return 'stopped';
1811
+ }
1812
+ catch {
1813
+ return 'not_installed';
1741
1814
  }
1742
1815
  }
1816
+ /** Type guard for execSync errors with a status code. */
1817
+ function isExecError(err) {
1818
+ return (typeof err === 'object' &&
1819
+ err !== null &&
1820
+ 'status' in err &&
1821
+ typeof err.status === 'number');
1822
+ }
1823
+
1743
1824
  /**
1744
- * Read and parse a JSON file, returning an empty object if not found.
1825
+ * Factory for platform-aware service lifecycle management.
1745
1826
  *
1746
- * @param filePath - Path to the JSON file.
1747
- * @returns Parsed object.
1827
+ * @remarks
1828
+ * Produces a `ServiceManager` that handles install, uninstall, start,
1829
+ * stop, restart, and status for system services. Delegates to NSSM
1830
+ * (Windows), systemd (Linux), or launchd (macOS) based on platform.
1748
1831
  */
1749
- function readJsonFile(filePath) {
1832
+ /** Exec helper that returns stdout. */
1833
+ function run$1(cmd) {
1834
+ return execSync(cmd, {
1835
+ encoding: 'utf-8',
1836
+ timeout: 30_000,
1837
+ stdio: ['pipe', 'pipe', 'pipe'],
1838
+ }).trim();
1839
+ }
1840
+ /** Exec helper that suppresses errors and returns success boolean. */
1841
+ function runQuiet(cmd) {
1750
1842
  try {
1751
- const raw = readFileSync(filePath, 'utf-8');
1752
- return JSON.parse(raw);
1843
+ execSync(cmd, {
1844
+ encoding: 'utf-8',
1845
+ timeout: 30_000,
1846
+ stdio: ['pipe', 'pipe', 'pipe'],
1847
+ });
1848
+ return true;
1753
1849
  }
1754
1850
  catch {
1755
- return {};
1851
+ return false;
1756
1852
  }
1757
1853
  }
1758
-
1759
1854
  /**
1760
- * Factory for the standard `-openclaw` plugin installer CLI.
1855
+ * Resolve the effective service name from options and descriptor.
1761
1856
  *
1762
- * @module
1857
+ * @param descriptor - Component descriptor.
1858
+ * @param options - Optional overrides.
1859
+ * @returns The service name to use.
1763
1860
  */
1861
+ function resolveServiceName(descriptor, options) {
1862
+ return options?.name ?? getEffectiveServiceName(descriptor);
1863
+ }
1764
1864
  /**
1765
- * Create a standard plugin installer CLI program.
1865
+ * Resolve the config path for install.
1766
1866
  *
1767
- * @param options - Plugin CLI configuration.
1768
- * @returns A Commander program ready for `.parse()`.
1867
+ * @param descriptor - Component descriptor.
1868
+ * @param options - Optional overrides.
1869
+ * @returns Absolute config file path.
1769
1870
  */
1770
- function createPluginCli(options) {
1771
- const { pluginId, distDir, pluginPackage, configRoot = 'j:/config', } = options;
1772
- const componentName = options.componentName ?? deriveComponentName(pluginId);
1773
- const program = new Command()
1774
- .name(pluginPackage)
1775
- .description(`Jeeves ${componentName} plugin installer`);
1776
- program
1777
- .command('install')
1778
- .description(`Install the ${componentName} plugin`)
1779
- .option('--memory', 'Claim a memory slot for this plugin')
1780
- .option('-w, --workspace <path>', 'Workspace root path')
1781
- .option('-c, --config-root <path>', 'Platform config root path', configRoot)
1782
- .action((opts) => {
1783
- const openClawHome = resolveOpenClawHome();
1784
- const configPath = resolveConfigPath(openClawHome);
1785
- // 1. Copy dist to extensions
1786
- const extensionsDir = join(openClawHome, 'extensions', pluginId);
1787
- console.log(`Copying dist to ${extensionsDir}...`);
1788
- copyDistFiles(distDir, extensionsDir);
1789
- // Copy package.json and openclaw.plugin.json from package root
1790
- const pkgRoot = packageDirectorySync({ cwd: distDir });
1791
- if (pkgRoot) {
1792
- for (const file of ['package.json', 'openclaw.plugin.json']) {
1793
- const src = join(pkgRoot, file);
1794
- if (existsSync(src)) {
1795
- copyFileSync(src, join(extensionsDir, file));
1796
- }
1797
- }
1798
- }
1799
- console.log(' ✓ Dist files copied');
1800
- // 2. Patch openclaw.json
1801
- console.log('Patching OpenClaw config...');
1802
- const config = readJsonFile(configPath);
1803
- const pkgJsonPathForVersion = join(extensionsDir, 'package.json');
1804
- let pluginVersionForRecord;
1805
- try {
1806
- const pkgJsonForRecord = readJsonFile(pkgJsonPathForVersion);
1807
- pluginVersionForRecord =
1808
- typeof pkgJsonForRecord.version === 'string'
1809
- ? pkgJsonForRecord.version
1810
- : undefined;
1811
- }
1812
- catch {
1813
- // best-effort: version may not be available yet
1814
- }
1815
- const messages = patchConfig(config, pluginId, 'add', {
1816
- installPath: extensionsDir,
1817
- version: pluginVersionForRecord,
1818
- });
1819
- // 3. Memory slot claim
1820
- if (opts.memory) {
1821
- if (!config.agents || typeof config.agents !== 'object') {
1822
- config.agents = {};
1823
- }
1824
- const agents = config.agents;
1825
- if (!agents.defaults || typeof agents.defaults !== 'object') {
1826
- agents.defaults = {};
1827
- }
1828
- const defaults = agents.defaults;
1829
- if (!defaults.memory || typeof defaults.memory !== 'object') {
1830
- defaults.memory = {};
1831
- }
1832
- const memory = defaults.memory;
1833
- if (!memory[componentName]) {
1834
- memory[componentName] = {};
1835
- messages.push(`Claimed memory slot for "${componentName}"`);
1871
+ function resolveConfigFilePath(descriptor, options) {
1872
+ if (options?.configPath)
1873
+ return options.configPath;
1874
+ const configDir = getComponentConfigDir(descriptor.name);
1875
+ return join(configDir, descriptor.configFileName);
1876
+ }
1877
+ /** Build a Windows NSSM service manager. */
1878
+ function createWindowsManager(descriptor) {
1879
+ return {
1880
+ install(options) {
1881
+ const svcName = resolveServiceName(descriptor, options);
1882
+ const cfgPath = resolveConfigFilePath(descriptor, options);
1883
+ const cmdArgs = descriptor.startCommand(cfgPath);
1884
+ const appPath = cmdArgs[0];
1885
+ const appArgs = cmdArgs.slice(1).join(' ');
1886
+ run$1(`nssm install ${svcName} ${appPath}`);
1887
+ if (appArgs) {
1888
+ run$1(`nssm set ${svcName} AppParameters ${appArgs}`);
1836
1889
  }
1837
- }
1838
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1839
- for (const msg of messages) {
1840
- console.log(` ✓ ${msg}`);
1841
- }
1842
- // 4. Write initial HEARTBEAT entry and seed jeeves skill
1843
- try {
1844
- const cfgRoot = opts.configRoot;
1845
- const agents = config.agents;
1846
- const defaults = agents?.defaults;
1847
- const ws = opts.workspace ?? defaults?.workspace;
1848
- if (ws) {
1849
- init({ workspacePath: ws, configRoot: cfgRoot });
1850
- const heartbeatPath = join(ws, WORKSPACE_FILES.heartbeat);
1851
- try {
1852
- const existing = existsSync(heartbeatPath)
1853
- ? readFileSync(heartbeatPath, 'utf-8')
1854
- : '';
1855
- const parsed = parseHeartbeat(existing);
1856
- const fullName = `jeeves-${componentName}`;
1857
- const hasEntry = parsed.entries.some((e) => e.name === fullName);
1858
- if (!hasEntry) {
1859
- parsed.entries.push({
1860
- name: fullName,
1861
- declined: false,
1862
- content: `- Plugin installed. Awaiting service configuration.`,
1863
- });
1864
- const section = buildHeartbeatSection(parsed.entries);
1865
- atomicWrite(heartbeatPath, section);
1866
- console.log(' ✓ HEARTBEAT entry written');
1867
- }
1868
- }
1869
- catch {
1870
- console.log(' ⚠ Could not write HEARTBEAT entry');
1871
- }
1872
- try {
1873
- seedSkill(ws);
1874
- console.log(' ✓ Jeeves skill seeded');
1875
- }
1876
- catch {
1877
- console.log(' ⚠ Could not seed Jeeves skill');
1878
- }
1879
- }
1880
- }
1881
- catch {
1882
- // HEARTBEAT + skill seeding are best-effort during install
1883
- }
1884
- // 5. Write component version
1885
- try {
1886
- init({
1887
- workspacePath: opts.workspace ?? '.',
1888
- configRoot: opts.configRoot,
1889
- });
1890
- const pkgJsonPath = join(extensionsDir, 'package.json');
1891
- const pkgJson = readJsonFile(pkgJsonPath);
1892
- const pluginVersion = typeof pkgJson.version === 'string' ? pkgJson.version : undefined;
1893
- writeComponentVersion(getCoreConfigDir(), {
1894
- componentName,
1895
- pluginPackage,
1896
- pluginVersion,
1897
- });
1898
- console.log(' ✓ Component version written');
1899
- }
1900
- catch {
1901
- console.log(' ⚠ Could not write component version');
1902
- }
1903
- console.log();
1904
- console.log(`✅ ${pluginPackage} installed.`);
1905
- });
1906
- program
1907
- .command('uninstall')
1908
- .description(`Uninstall the ${componentName} plugin`)
1909
- .option('-w, --workspace <path>', 'Workspace root path')
1910
- .option('-c, --config-root <path>', 'Platform config root path', configRoot)
1911
- .action(async (opts) => {
1912
- const openClawHome = resolveOpenClawHome();
1913
- const cfgPath = resolveConfigPath(openClawHome);
1914
- // 1. Remove from extensions
1915
- const extensionsDir = join(openClawHome, 'extensions', pluginId);
1916
- if (existsSync(extensionsDir)) {
1917
- rmSync(extensionsDir, { recursive: true, force: true });
1918
- console.log(' ✓ Extension files removed');
1919
- }
1920
- // 2. Unpatch openclaw.json
1921
- if (existsSync(cfgPath)) {
1922
- const config = readJsonFile(cfgPath);
1923
- const messages = patchConfig(config, pluginId, 'remove');
1924
- writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n');
1925
- for (const msg of messages) {
1926
- console.log(` ✓ ${msg}`);
1927
- }
1928
- }
1929
- // 3. Remove TOOLS.md section
1930
- try {
1931
- const ws = opts.workspace;
1932
- if (ws) {
1933
- init({ workspacePath: ws, configRoot: opts.configRoot });
1934
- const sectionId = componentName.charAt(0).toUpperCase() + componentName.slice(1);
1935
- const toolsPath = join(ws, WORKSPACE_FILES.tools);
1936
- if (existsSync(toolsPath)) {
1937
- await removeManagedSection(toolsPath, {
1938
- sectionId,
1939
- markers: TOOLS_MARKERS,
1940
- });
1941
- console.log(' ✓ TOOLS.md section removed');
1942
- }
1943
- }
1944
- }
1945
- catch {
1946
- console.log(' ⚠ Could not remove TOOLS.md section');
1947
- }
1948
- // 4. Remove component-versions.json entry
1949
- try {
1950
- init({
1951
- workspacePath: opts.workspace ?? '.',
1952
- configRoot: opts.configRoot,
1953
- });
1954
- removeComponentVersion(getCoreConfigDir(), componentName);
1955
- console.log(' ✓ Component version entry removed');
1956
- }
1957
- catch {
1958
- console.log(' ⚠ Could not remove component version entry');
1959
- }
1960
- console.log();
1961
- console.log(`✅ ${pluginPackage} uninstalled.`);
1890
+ run$1(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
1891
+ run$1(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
1892
+ run$1(`nssm set ${svcName} AppRotateFiles 1`);
1893
+ run$1(`nssm set ${svcName} AppRotateBytes 1048576`);
1894
+ },
1895
+ uninstall(options) {
1896
+ const svcName = resolveServiceName(descriptor, options);
1897
+ runQuiet(`nssm stop ${svcName}`);
1898
+ run$1(`nssm remove ${svcName} confirm`);
1899
+ },
1900
+ start(options) {
1901
+ const svcName = resolveServiceName(descriptor, options);
1902
+ run$1(`nssm start ${svcName}`);
1903
+ },
1904
+ stop(options) {
1905
+ const svcName = resolveServiceName(descriptor, options);
1906
+ run$1(`nssm stop ${svcName}`);
1907
+ },
1908
+ restart(options) {
1909
+ const svcName = resolveServiceName(descriptor, options);
1910
+ run$1(`nssm restart ${svcName}`);
1911
+ },
1912
+ status(options) {
1913
+ const svcName = resolveServiceName(descriptor, options);
1914
+ return getServiceState(svcName);
1915
+ },
1916
+ };
1917
+ }
1918
+ /**
1919
+ * Generate a systemd user unit file.
1920
+ *
1921
+ * @param svcName - Service name.
1922
+ * @param cmdArgs - Command + args array.
1923
+ * @returns Unit file content.
1924
+ */
1925
+ function buildSystemdUnit(svcName, cmdArgs) {
1926
+ const execStart = cmdArgs.join(' ');
1927
+ return [
1928
+ '[Unit]',
1929
+ `Description=${svcName}`,
1930
+ 'After=network.target',
1931
+ '',
1932
+ '[Service]',
1933
+ 'Type=simple',
1934
+ `ExecStart=${execStart}`,
1935
+ 'Restart=on-failure',
1936
+ 'RestartSec=5',
1937
+ '',
1938
+ '[Install]',
1939
+ 'WantedBy=default.target',
1940
+ ].join('\n');
1941
+ }
1942
+ /** Build a Linux systemd service manager. */
1943
+ function createLinuxManager(descriptor) {
1944
+ const unitDir = join(homedir(), '.config', 'systemd', 'user');
1945
+ function unitPath(svcName) {
1946
+ return join(unitDir, `${svcName}.service`);
1947
+ }
1948
+ return {
1949
+ install(options) {
1950
+ const svcName = resolveServiceName(descriptor, options);
1951
+ const cfgPath = resolveConfigFilePath(descriptor, options);
1952
+ const cmdArgs = descriptor.startCommand(cfgPath);
1953
+ mkdirSync(unitDir, { recursive: true });
1954
+ writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
1955
+ run$1('systemctl --user daemon-reload');
1956
+ run$1(`systemctl --user enable ${svcName}.service`);
1957
+ },
1958
+ uninstall(options) {
1959
+ const svcName = resolveServiceName(descriptor, options);
1960
+ runQuiet(`systemctl --user stop ${svcName}.service`);
1961
+ runQuiet(`systemctl --user disable ${svcName}.service`);
1962
+ const path = unitPath(svcName);
1963
+ if (existsSync(path))
1964
+ unlinkSync(path);
1965
+ runQuiet('systemctl --user daemon-reload');
1966
+ },
1967
+ start(options) {
1968
+ const svcName = resolveServiceName(descriptor, options);
1969
+ run$1(`systemctl --user start ${svcName}.service`);
1970
+ },
1971
+ stop(options) {
1972
+ const svcName = resolveServiceName(descriptor, options);
1973
+ run$1(`systemctl --user stop ${svcName}.service`);
1974
+ },
1975
+ restart(options) {
1976
+ const svcName = resolveServiceName(descriptor, options);
1977
+ run$1(`systemctl --user restart ${svcName}.service`);
1978
+ },
1979
+ status(options) {
1980
+ const svcName = resolveServiceName(descriptor, options);
1981
+ return getServiceState(svcName);
1982
+ },
1983
+ };
1984
+ }
1985
+ /**
1986
+ * Generate a macOS launchd plist.
1987
+ *
1988
+ * @param svcName - Service label.
1989
+ * @param cmdArgs - Command + args array.
1990
+ * @returns Plist XML content.
1991
+ */
1992
+ function buildLaunchdPlist(svcName, cmdArgs) {
1993
+ const argsXml = cmdArgs.map((a) => ` <string>${a}</string>`).join('\n');
1994
+ return [
1995
+ '<?xml version="1.0" encoding="UTF-8"?>',
1996
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"',
1997
+ ' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1998
+ '<plist version="1.0">',
1999
+ '<dict>',
2000
+ ' <key>Label</key>',
2001
+ ` <string>${svcName}</string>`,
2002
+ ' <key>ProgramArguments</key>',
2003
+ ' <array>',
2004
+ argsXml,
2005
+ ' </array>',
2006
+ ' <key>RunAtLoad</key>',
2007
+ ' <true/>',
2008
+ ' <key>KeepAlive</key>',
2009
+ ' <true/>',
2010
+ ' <key>StandardOutPath</key>',
2011
+ ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
2012
+ ' <key>StandardErrorPath</key>',
2013
+ ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
2014
+ '</dict>',
2015
+ '</plist>',
2016
+ ].join('\n');
2017
+ }
2018
+ /** Build a macOS launchd service manager. */
2019
+ function createMacOSManager(descriptor) {
2020
+ const agentsDir = join(homedir(), 'Library', 'LaunchAgents');
2021
+ function plistPath(svcName) {
2022
+ return join(agentsDir, `${svcName}.plist`);
2023
+ }
2024
+ return {
2025
+ install(options) {
2026
+ const svcName = resolveServiceName(descriptor, options);
2027
+ const cfgPath = resolveConfigFilePath(descriptor, options);
2028
+ const cmdArgs = descriptor.startCommand(cfgPath);
2029
+ mkdirSync(agentsDir, { recursive: true });
2030
+ writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
2031
+ },
2032
+ uninstall(options) {
2033
+ const svcName = resolveServiceName(descriptor, options);
2034
+ runQuiet(`launchctl unload ${plistPath(svcName)}`);
2035
+ const path = plistPath(svcName);
2036
+ if (existsSync(path))
2037
+ unlinkSync(path);
2038
+ },
2039
+ start(options) {
2040
+ const svcName = resolveServiceName(descriptor, options);
2041
+ run$1(`launchctl load ${plistPath(svcName)}`);
2042
+ },
2043
+ stop(options) {
2044
+ const svcName = resolveServiceName(descriptor, options);
2045
+ run$1(`launchctl unload ${plistPath(svcName)}`);
2046
+ },
2047
+ restart(options) {
2048
+ const svcName = resolveServiceName(descriptor, options);
2049
+ runQuiet(`launchctl unload ${plistPath(svcName)}`);
2050
+ run$1(`launchctl load ${plistPath(svcName)}`);
2051
+ },
2052
+ status(options) {
2053
+ const svcName = resolveServiceName(descriptor, options);
2054
+ return getServiceState(svcName);
2055
+ },
2056
+ };
2057
+ }
2058
+ /**
2059
+ * Create a platform-aware service manager from a component descriptor.
2060
+ *
2061
+ * @remarks
2062
+ * Detects the current platform and returns a `ServiceManager` that
2063
+ * delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
2064
+ *
2065
+ * @param descriptor - The component descriptor.
2066
+ * @returns A `ServiceManager` for the current platform.
2067
+ */
2068
+ function createServiceManager(descriptor) {
2069
+ switch (process.platform) {
2070
+ case 'win32':
2071
+ return createWindowsManager(descriptor);
2072
+ case 'darwin':
2073
+ return createMacOSManager(descriptor);
2074
+ default:
2075
+ return createLinuxManager(descriptor);
2076
+ }
2077
+ }
2078
+
2079
+ /**
2080
+ * HTTP helpers for the OpenClaw plugin SDK.
2081
+ *
2082
+ * @remarks
2083
+ * Thin wrappers around `fetch` that throw on non-OK responses
2084
+ * and handle JSON serialisation/deserialisation.
2085
+ */
2086
+ /**
2087
+ * Fetch a URL with an automatic abort timeout.
2088
+ *
2089
+ * @param url - URL to fetch.
2090
+ * @param timeoutMs - Timeout in milliseconds before aborting.
2091
+ * @param init - Optional `fetch` init options.
2092
+ * @returns The fetch Response object.
2093
+ */
2094
+ async function fetchWithTimeout(url, timeoutMs, init) {
2095
+ const controller = new AbortController();
2096
+ const timeout = setTimeout(() => {
2097
+ controller.abort();
2098
+ }, timeoutMs);
2099
+ try {
2100
+ return await fetch(url, { ...init, signal: controller.signal });
2101
+ }
2102
+ finally {
2103
+ clearTimeout(timeout);
2104
+ }
2105
+ }
2106
+ /**
2107
+ * Fetch JSON from a URL, throwing on non-OK responses.
2108
+ *
2109
+ * @param url - URL to fetch.
2110
+ * @param init - Optional `fetch` init options.
2111
+ * @returns Parsed JSON response body.
2112
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
2113
+ */
2114
+ async function fetchJson(url, init) {
2115
+ const res = await fetch(url, init);
2116
+ if (!res.ok) {
2117
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
2118
+ }
2119
+ return res.json();
2120
+ }
2121
+ /**
2122
+ * POST JSON to a URL and return parsed response.
2123
+ *
2124
+ * @param url - URL to POST to.
2125
+ * @param body - Request body (will be JSON-stringified).
2126
+ * @returns Parsed JSON response body.
2127
+ */
2128
+ async function postJson(url, body) {
2129
+ return fetchJson(url, {
2130
+ method: 'POST',
2131
+ headers: { 'Content-Type': 'application/json' },
2132
+ body: JSON.stringify(body),
1962
2133
  });
1963
- return program;
1964
2134
  }
1965
2135
 
1966
2136
  /**
1967
- * Zod schema for the Jeeves component descriptor.
2137
+ * Tool result formatters for the OpenClaw plugin SDK.
1968
2138
  *
1969
2139
  * @remarks
1970
- * The descriptor replaces the v0.4.0 `JeevesComponent` interface with a
1971
- * Zod-first approach. The TypeScript type is inferred via `z.infer<>`.
1972
- * Validates at parse time: prime interval, callable functions.
2140
+ * Provides standardised helpers for building `ToolResult` objects:
2141
+ * success, error, and connection-error variants.
1973
2142
  */
1974
2143
  /**
1975
- * Check whether a number is prime.
2144
+ * Format a successful tool result.
2145
+ *
2146
+ * @param data - Arbitrary data to return as JSON.
2147
+ * @returns A `ToolResult` with JSON-stringified content.
2148
+ */
2149
+ function ok(data) {
2150
+ return {
2151
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
2152
+ };
2153
+ }
2154
+ /**
2155
+ * Format an error tool result.
2156
+ *
2157
+ * @param error - Error instance, string, or other value.
2158
+ * @returns A `ToolResult` with `isError: true`.
2159
+ */
2160
+ function fail(error) {
2161
+ const message = error instanceof Error ? error.message : String(error);
2162
+ return {
2163
+ content: [{ type: 'text', text: 'Error: ' + message }],
2164
+ isError: true,
2165
+ };
2166
+ }
2167
+ /**
2168
+ * Format a connection error with actionable guidance.
2169
+ *
2170
+ * @remarks
2171
+ * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
2172
+ * `error.cause.code` and returns a user-friendly message referencing
2173
+ * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
2174
+ * non-connection errors.
1976
2175
  *
1977
- * @param n - Number to check.
1978
- * @returns `true` if n is prime.
2176
+ * @param error - Error instance (typically from `fetch`).
2177
+ * @param baseUrl - The URL that was being contacted.
2178
+ * @param pluginId - The plugin identifier for config guidance.
2179
+ * @returns A `ToolResult` with `isError: true`.
1979
2180
  */
1980
- function isPrime(n) {
1981
- if (n < 2)
1982
- return false;
1983
- if (n === 2)
1984
- return true;
1985
- if (n % 2 === 0)
1986
- return false;
1987
- for (let i = 3; i * i <= n; i += 2) {
1988
- if (n % i === 0)
1989
- return false;
2181
+ function connectionFail(error, baseUrl, pluginId) {
2182
+ const cause = error instanceof Error ? error.cause : undefined;
2183
+ const code = cause && typeof cause === 'object' && 'code' in cause
2184
+ ? String(cause.code)
2185
+ : '';
2186
+ const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
2187
+ if (isConnectionError) {
2188
+ return {
2189
+ content: [
2190
+ {
2191
+ type: 'text',
2192
+ text: [
2193
+ `Service not reachable at ${baseUrl}.`,
2194
+ 'Either start the service, or if it runs on a different port,',
2195
+ `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
2196
+ ].join('\n'),
2197
+ },
2198
+ ],
2199
+ isError: true,
2200
+ };
1990
2201
  }
1991
- return true;
2202
+ return fail(error);
1992
2203
  }
2204
+
1993
2205
  /**
1994
- * Zod schema for the Jeeves component descriptor.
2206
+ * Factory for the standard plugin tool set.
1995
2207
  *
1996
2208
  * @remarks
1997
- * Single source of truth for what a component must provide.
1998
- * Factories consume this descriptor to produce CLI commands,
1999
- * plugin tools, and HTTP handlers.
2209
+ * Produces four standard tools from a component descriptor:
2210
+ * - `{name}_status` - Probe service health + version + uptime
2211
+ * - `{name}_config` - Query running config with optional JSONPath
2212
+ * - `{name}_config_apply` - Push config patch to running service
2213
+ * - `{name}_service` - Service lifecycle management
2214
+ *
2215
+ * Components add domain-specific tools separately.
2000
2216
  */
2001
- const jeevesComponentDescriptorSchema = z.object({
2002
- /** Component name (e.g., 'watcher', 'runner', 'server', 'meta'). */
2003
- name: z.string().min(1, 'name must be a non-empty string'),
2004
- /** Component version (from package.json). */
2005
- version: z.string().min(1, 'version must be a non-empty string'),
2006
- /** npm package name for the service. */
2007
- servicePackage: z.string().min(1),
2008
- /** npm package name for the plugin. */
2009
- pluginPackage: z.string().min(1),
2010
- /** System service name. Defaults to `jeeves-${name}` when not provided. */
2011
- serviceName: z.string().min(1).optional(),
2012
- /** Default port for the service's HTTP API. */
2013
- defaultPort: z.number().int().positive(),
2014
- /** Zod schema for validating config files. */
2015
- configSchema: z.custom((val) => val !== null &&
2016
- typeof val === 'object' &&
2017
- typeof val.parse === 'function', { message: 'configSchema must be a Zod schema' }),
2018
- /** Config file name (e.g., 'jeeves-watcher.config.json'). */
2019
- configFileName: z.string().min(1),
2020
- /** Returns a default config object for `init`. */
2021
- initTemplate: z.function({
2022
- input: [],
2023
- output: z.record(z.string(), z.unknown()),
2024
- }),
2025
- /**
2026
- * Service-side callback after config apply. Receives the merged,
2027
- * validated config (not the raw patch). Optional — if omitted,
2028
- * write-only (service picks up changes on restart).
2029
- */
2030
- onConfigApply: z
2031
- .function({
2032
- input: [z.record(z.string(), z.unknown())],
2033
- output: z.promise(z.void()),
2034
- })
2035
- .optional(),
2036
- /**
2037
- * Custom merge function for config apply. Receives the existing config
2038
- * and the patch, returns the merged result. Optional — if omitted,
2039
- * the default deep-merge (object-recursive, array-replacing) is used.
2040
- *
2041
- * Use this to implement domain-specific merge strategies such as
2042
- * name-based array merging for inference rules.
2043
- */
2044
- customMerge: z
2045
- .function({
2046
- input: [
2047
- z.record(z.string(), z.unknown()),
2048
- z.record(z.string(), z.unknown()),
2049
- ],
2050
- output: z.record(z.string(), z.unknown()),
2051
- })
2052
- .optional(),
2053
- /**
2054
- * Returns command + args for launching the service process.
2055
- * Consumed by `service install`.
2056
- */
2057
- startCommand: z.function({
2058
- input: [z.string()],
2059
- output: z.array(z.string()),
2060
- }),
2061
- /** In-process service entry point for the CLI `start` command. */
2062
- run: z.function({
2063
- input: [z.string()],
2064
- output: z.promise(z.void()),
2065
- }),
2066
- /** TOOLS.md section name (e.g., 'Watcher'). */
2067
- sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
2068
- /** Refresh interval in seconds (must be a prime number). */
2069
- refreshIntervalSeconds: z.number().int().positive().refine(isPrime, {
2070
- message: 'refreshIntervalSeconds must be a prime number',
2071
- }),
2072
- /** Produce the component's TOOLS.md section content. */
2073
- generateToolsContent: z.function({ input: [], output: z.string() }),
2074
- /** Component dependencies for HEARTBEAT alert suppression. */
2075
- dependencies: z
2076
- .object({
2077
- /** Components that must be healthy for this component to function. */
2078
- hard: z.array(z.string()),
2079
- /** Components that improve behavior but are not strictly required. */
2080
- soft: z.array(z.string()),
2081
- })
2082
- .optional(),
2083
- /** Extension point: add custom CLI commands to the service CLI. */
2084
- customCliCommands: z
2085
- .function({ input: [z.custom()], output: z.void() })
2086
- .optional(),
2087
- /** Extension point: return additional plugin tool descriptors. */
2088
- customPluginTools: z
2089
- .function({ input: [z.custom()], output: z.array(z.unknown()) })
2090
- .optional(),
2091
- });
2217
+ /** Timeout for HTTP probes in milliseconds. */
2218
+ const PROBE_TIMEOUT_MS$1 = 5000;
2092
2219
  /**
2093
- * Derive the effective service name from a descriptor.
2220
+ * Create the standard plugin tool set from a component descriptor.
2094
2221
  *
2095
2222
  * @param descriptor - The component descriptor.
2096
- * @returns The service name (explicit or derived from `jeeves-{name}`).
2223
+ * @returns Array of tool descriptors to register.
2097
2224
  */
2098
- function getEffectiveServiceName(descriptor) {
2099
- return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
2225
+ function createPluginToolset(descriptor) {
2226
+ const { name, defaultPort } = descriptor;
2227
+ const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
2228
+ const svcManager = createServiceManager(descriptor);
2229
+ const statusTool = {
2230
+ name: `${name}_status`,
2231
+ description: `Get ${name} service health, version, and uptime.`,
2232
+ parameters: {
2233
+ type: 'object',
2234
+ properties: {},
2235
+ },
2236
+ execute: async () => {
2237
+ try {
2238
+ const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS$1);
2239
+ if (!res.ok) {
2240
+ return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
2241
+ }
2242
+ const data = await res.json();
2243
+ return ok(data);
2244
+ }
2245
+ catch (err) {
2246
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
2247
+ }
2248
+ },
2249
+ };
2250
+ const configTool = {
2251
+ name: `${name}_config`,
2252
+ description: `Query ${name} running configuration. Optional JSONPath filter.`,
2253
+ parameters: {
2254
+ type: 'object',
2255
+ properties: {
2256
+ path: {
2257
+ type: 'string',
2258
+ description: 'JSONPath expression (optional)',
2259
+ },
2260
+ },
2261
+ },
2262
+ execute: async (_id, params) => {
2263
+ const path = params.path;
2264
+ const qs = path ? `?path=${encodeURIComponent(path)}` : '';
2265
+ try {
2266
+ const result = await fetchJson(`${baseUrl}/config${qs}`);
2267
+ return ok(result);
2268
+ }
2269
+ catch (err) {
2270
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
2271
+ }
2272
+ },
2273
+ };
2274
+ const configApplyTool = {
2275
+ name: `${name}_config_apply`,
2276
+ description: `Apply a config patch to the running ${name} service.`,
2277
+ parameters: {
2278
+ type: 'object',
2279
+ properties: {
2280
+ config: {
2281
+ type: 'object',
2282
+ description: 'Config patch to apply',
2283
+ },
2284
+ },
2285
+ required: ['config'],
2286
+ },
2287
+ execute: async (_id, params) => {
2288
+ const config = params.config;
2289
+ if (!config) {
2290
+ return fail('Missing required parameter: config');
2291
+ }
2292
+ try {
2293
+ const result = await postJson(`${baseUrl}/config/apply`, {
2294
+ patch: config,
2295
+ });
2296
+ return ok(result);
2297
+ }
2298
+ catch (err) {
2299
+ return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
2300
+ }
2301
+ },
2302
+ };
2303
+ const serviceTool = {
2304
+ name: `${name}_service`,
2305
+ description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
2306
+ parameters: {
2307
+ type: 'object',
2308
+ properties: {
2309
+ action: {
2310
+ type: 'string',
2311
+ enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
2312
+ description: 'Service action to perform',
2313
+ },
2314
+ },
2315
+ required: ['action'],
2316
+ },
2317
+ execute: (_id, params) => {
2318
+ const action = params.action;
2319
+ const validActions = [
2320
+ 'install',
2321
+ 'uninstall',
2322
+ 'start',
2323
+ 'stop',
2324
+ 'restart',
2325
+ 'status',
2326
+ ];
2327
+ if (!validActions.includes(action)) {
2328
+ return Promise.resolve(fail(`Invalid action: ${action}`));
2329
+ }
2330
+ try {
2331
+ if (action === 'status') {
2332
+ const state = svcManager.status();
2333
+ return Promise.resolve(ok({ service: name, state }));
2334
+ }
2335
+ // Call the appropriate method
2336
+ const methodMap = {
2337
+ install: () => {
2338
+ svcManager.install();
2339
+ },
2340
+ uninstall: () => {
2341
+ svcManager.uninstall();
2342
+ },
2343
+ start: () => {
2344
+ svcManager.start();
2345
+ },
2346
+ stop: () => {
2347
+ svcManager.stop();
2348
+ },
2349
+ restart: () => {
2350
+ svcManager.restart();
2351
+ },
2352
+ };
2353
+ methodMap[action]();
2354
+ return Promise.resolve(ok({ service: name, action, success: true }));
2355
+ }
2356
+ catch (err) {
2357
+ return Promise.resolve(fail(`Service ${action} failed: ${getErrorMessage(err)}`));
2358
+ }
2359
+ },
2360
+ };
2361
+ return [statusTool, configTool, configApplyTool, serviceTool];
2100
2362
  }
2101
2363
 
2102
2364
  /**
2103
- * HTTP helpers for the OpenClaw plugin SDK.
2365
+ * Resolve the package root directory from a module's `import.meta.url`.
2104
2366
  *
2105
- * @remarks
2106
- * Thin wrappers around `fetch` that throw on non-OK responses
2107
- * and handle JSON serialisation/deserialisation.
2367
+ * @module
2108
2368
  */
2109
2369
  /**
2110
- * Fetch a URL with an automatic abort timeout.
2370
+ * Get the nearest package root directory relative to the calling module URL.
2111
2371
  *
2112
- * @param url - URL to fetch.
2113
- * @param timeoutMs - Timeout in milliseconds before aborting.
2114
- * @param init - Optional `fetch` init options.
2115
- * @returns The fetch Response object.
2372
+ * @param importMetaUrl - The `import.meta.url` of the calling module.
2373
+ * @returns The absolute package root path, or `undefined` on any error.
2116
2374
  */
2117
- async function fetchWithTimeout(url, timeoutMs, init) {
2118
- const controller = new AbortController();
2119
- const timeout = setTimeout(() => {
2120
- controller.abort();
2121
- }, timeoutMs);
2375
+ function getPackageRoot(importMetaUrl) {
2122
2376
  try {
2123
- return await fetch(url, { ...init, signal: controller.signal });
2377
+ return packageDirectorySync({ cwd: fileURLToPath(importMetaUrl) });
2124
2378
  }
2125
- finally {
2126
- clearTimeout(timeout);
2379
+ catch {
2380
+ return undefined;
2127
2381
  }
2128
2382
  }
2383
+
2129
2384
  /**
2130
- * Fetch JSON from a URL, throwing on non-OK responses.
2385
+ * Resolve the version of a package from its `import.meta.url`.
2131
2386
  *
2132
- * @param url - URL to fetch.
2133
- * @param init - Optional `fetch` init options.
2134
- * @returns Parsed JSON response body.
2135
- * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
2387
+ * @module
2136
2388
  */
2137
- async function fetchJson(url, init) {
2138
- const res = await fetch(url, init);
2139
- if (!res.ok) {
2140
- throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
2141
- }
2142
- return res.json();
2143
- }
2144
2389
  /**
2145
- * POST JSON to a URL and return parsed response.
2390
+ * Get the version string from the nearest `package.json` relative to the
2391
+ * caller's module URL.
2146
2392
  *
2147
- * @param url - URL to POST to.
2148
- * @param body - Request body (will be JSON-stringified).
2149
- * @returns Parsed JSON response body.
2393
+ * @param importMetaUrl - The `import.meta.url` of the calling module.
2394
+ * @returns The `version` field, or `'unknown'` on any error.
2150
2395
  */
2151
- async function postJson(url, body) {
2152
- return fetchJson(url, {
2153
- method: 'POST',
2154
- headers: { 'Content-Type': 'application/json' },
2155
- body: JSON.stringify(body),
2156
- });
2396
+ function getPackageVersion(importMetaUrl) {
2397
+ try {
2398
+ const pkgRoot = getPackageRoot(importMetaUrl);
2399
+ if (!pkgRoot)
2400
+ return 'unknown';
2401
+ const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf-8');
2402
+ const pkg = JSON.parse(raw);
2403
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
2404
+ }
2405
+ catch {
2406
+ return 'unknown';
2407
+ }
2157
2408
  }
2158
2409
 
2159
2410
  /**
2160
- * Platform-aware service state detection.
2411
+ * OpenClaw configuration helpers for plugin CLI installers.
2161
2412
  *
2162
2413
  * @remarks
2163
- * Detects whether a system service is installed and running.
2164
- * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
2414
+ * Provides resolution of OpenClaw home directory and config file path,
2415
+ * plus idempotent config patching for plugin install/uninstall.
2165
2416
  */
2166
2417
  /**
2167
- * Detect the state of a system service by name.
2418
+ * Resolve the OpenClaw home directory.
2168
2419
  *
2169
- * @param serviceName - The service name (e.g., 'jeeves-runner').
2170
- * @returns The detected service state.
2420
+ * @remarks
2421
+ * Resolution order:
2422
+ * 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
2423
+ * 2. `OPENCLAW_HOME` env var → resolved path
2424
+ * 3. Default: `~/.openclaw`
2425
+ *
2426
+ * @returns Absolute path to the OpenClaw home directory.
2171
2427
  */
2172
- function getServiceState(serviceName) {
2173
- switch (process.platform) {
2174
- case 'win32':
2175
- return getServiceStateWindows(serviceName);
2176
- case 'darwin':
2177
- return getServiceStateMacOS(serviceName);
2178
- default:
2179
- return getServiceStateLinux(serviceName);
2428
+ function resolveOpenClawHome() {
2429
+ if (process.env.OPENCLAW_CONFIG) {
2430
+ return dirname(resolve(process.env.OPENCLAW_CONFIG));
2431
+ }
2432
+ if (process.env.OPENCLAW_HOME) {
2433
+ return resolve(process.env.OPENCLAW_HOME);
2180
2434
  }
2435
+ return join(homedir(), '.openclaw');
2181
2436
  }
2182
2437
  /**
2183
- * Windows: detect via NSSM.
2184
- * - Exit code 3 = service does not exist
2185
- * - "SERVICE_RUNNING" in output = running
2186
- * - Other output = stopped/paused
2438
+ * Resolve the OpenClaw config file path.
2439
+ *
2440
+ * @remarks
2441
+ * If `OPENCLAW_CONFIG` is set, uses that directly.
2442
+ * Otherwise defaults to `{home}/openclaw.json`.
2443
+ *
2444
+ * @param home - The OpenClaw home directory.
2445
+ * @returns Absolute path to the config file.
2187
2446
  */
2188
- function getServiceStateWindows(serviceName) {
2189
- try {
2190
- const output = execSync(`nssm status ${serviceName}`, {
2191
- encoding: 'utf-8',
2192
- timeout: 5000,
2193
- stdio: ['pipe', 'pipe', 'pipe'],
2194
- }).trim();
2195
- if (output.includes('SERVICE_RUNNING'))
2196
- return 'running';
2197
- return 'stopped';
2198
- }
2199
- catch (err) {
2200
- // NSSM exits with code 3 when the service doesn't exist
2201
- if (isExecError(err) && err.status === 3)
2202
- return 'not_installed';
2203
- // Any other error (nssm not found, timeout, etc.) — treat as not installed
2204
- return 'not_installed';
2447
+ function resolveConfigPath(home) {
2448
+ if (process.env.OPENCLAW_CONFIG) {
2449
+ return resolve(process.env.OPENCLAW_CONFIG);
2205
2450
  }
2451
+ return join(home, 'openclaw.json');
2206
2452
  }
2207
2453
  /**
2208
- * Linux: detect via systemd user services.
2209
- * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
2210
- * - `systemctl --user is-active {name}.service` returns "active" = running
2454
+ * Patch an allowlist array: add or remove the plugin ID.
2455
+ *
2456
+ * @returns A log message if a change was made, or undefined.
2211
2457
  */
2212
- function getServiceStateLinux(serviceName) {
2213
- try {
2214
- execSync(`systemctl --user is-enabled ${serviceName}.service`, {
2215
- encoding: 'utf-8',
2216
- timeout: 5000,
2217
- stdio: ['pipe', 'pipe', 'pipe'],
2218
- });
2219
- }
2220
- catch {
2221
- return 'not_installed';
2222
- }
2223
- try {
2224
- const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
2225
- encoding: 'utf-8',
2226
- timeout: 5000,
2227
- stdio: ['pipe', 'pipe', 'pipe'],
2228
- }).trim();
2229
- if (output === 'active')
2230
- return 'running';
2231
- return 'stopped';
2458
+ function patchAllowList(parent, key, label, pluginId, mode) {
2459
+ if (mode === 'add') {
2460
+ if (!Array.isArray(parent[key])) {
2461
+ parent[key] = [pluginId];
2462
+ return `Created ${label} with "${pluginId}"`;
2463
+ }
2464
+ const list = parent[key];
2465
+ if (!list.includes(pluginId)) {
2466
+ list.push(pluginId);
2467
+ return `Added "${pluginId}" to ${label}`;
2468
+ }
2232
2469
  }
2233
- catch {
2234
- return 'stopped';
2470
+ else {
2471
+ if (!Array.isArray(parent[key]))
2472
+ return undefined;
2473
+ const list = parent[key];
2474
+ const filtered = list.filter((id) => id !== pluginId);
2475
+ if (filtered.length !== list.length) {
2476
+ parent[key] = filtered;
2477
+ return `Removed "${pluginId}" from ${label}`;
2478
+ }
2235
2479
  }
2480
+ return undefined;
2236
2481
  }
2237
2482
  /**
2238
- * macOS: detect via launchctl.
2239
- * - `launchctl list {name}` exits non-zero = not installed
2240
- * - PID column is `-` or `0` = stopped
2483
+ * Patch an OpenClaw config for plugin install or uninstall.
2484
+ *
2485
+ * @remarks
2486
+ * Manages `plugins.entries.{pluginId}`, `plugins.installs.{pluginId}`,
2487
+ * and `tools.alsoAllow`.
2488
+ * Idempotent: adding twice produces no duplicates; removing when absent
2489
+ * produces no errors.
2490
+ *
2491
+ * @param config - The parsed OpenClaw config object (mutated in place).
2492
+ * @param pluginId - The plugin identifier.
2493
+ * @param mode - Whether to add or remove the plugin.
2494
+ * @param installRecord - Install provenance record (required when mode is 'add').
2495
+ * @returns Array of log messages describing changes made.
2241
2496
  */
2242
- function getServiceStateMacOS(serviceName) {
2243
- try {
2244
- const output = execSync(`launchctl list ${serviceName}`, {
2245
- encoding: 'utf-8',
2246
- timeout: 5000,
2247
- stdio: ['pipe', 'pipe', 'pipe'],
2248
- }).trim();
2249
- // launchctl list output formats:
2250
- // 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
2251
- // 2. Plist-style: '"PID" = 1234;'
2252
- // 3. Single service: first token is the PID or "-"
2253
- // Try table format: first token is PID
2254
- const tableMatch = /^(\d+|-)\s/m.exec(output);
2255
- if (tableMatch) {
2256
- const pid = tableMatch[1];
2257
- return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
2258
- }
2259
- // Try plist-style: "PID" = <number>;
2260
- const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
2261
- if (plistMatch) {
2262
- return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
2497
+ function patchConfig(config, pluginId, mode, installRecord) {
2498
+ const messages = [];
2499
+ // Ensure plugins section
2500
+ if (!config.plugins || typeof config.plugins !== 'object') {
2501
+ config.plugins = {};
2502
+ }
2503
+ const plugins = config.plugins;
2504
+ // plugins.entries
2505
+ if (!plugins.entries || typeof plugins.entries !== 'object') {
2506
+ plugins.entries = {};
2507
+ }
2508
+ const entries = plugins.entries;
2509
+ if (mode === 'add') {
2510
+ if (!entries[pluginId]) {
2511
+ entries[pluginId] = { enabled: true };
2512
+ messages.push(`Added "${pluginId}" to plugins.entries`);
2263
2513
  }
2264
- // If we got output but can't parse it, assume stopped (service exists but state unclear)
2265
- return 'stopped';
2266
2514
  }
2267
- catch {
2268
- return 'not_installed';
2515
+ else if (pluginId in entries) {
2516
+ Reflect.deleteProperty(entries, pluginId);
2517
+ messages.push(`Removed "${pluginId}" from plugins.entries`);
2269
2518
  }
2270
- }
2271
- /** Type guard for execSync errors with a status code. */
2272
- function isExecError(err) {
2273
- return (typeof err === 'object' &&
2274
- err !== null &&
2275
- 'status' in err &&
2276
- typeof err.status === 'number');
2519
+ // plugins.installs
2520
+ if (!plugins.installs || typeof plugins.installs !== 'object') {
2521
+ plugins.installs = {};
2522
+ }
2523
+ const installs = plugins.installs;
2524
+ if (mode === 'add' && installRecord) {
2525
+ installs[pluginId] = {
2526
+ source: 'path',
2527
+ installPath: installRecord.installPath,
2528
+ version: installRecord.version,
2529
+ installedAt: installRecord.installedAt ?? new Date().toISOString(),
2530
+ };
2531
+ messages.push(`Wrote install record for "${pluginId}" to plugins.installs`);
2532
+ }
2533
+ else if (mode === 'remove' && pluginId in installs) {
2534
+ Reflect.deleteProperty(installs, pluginId);
2535
+ messages.push(`Removed install record for "${pluginId}" from plugins.installs`);
2536
+ }
2537
+ // tools.alsoAllow
2538
+ if (!config.tools || typeof config.tools !== 'object') {
2539
+ config.tools = {};
2540
+ }
2541
+ const tools = config.tools;
2542
+ const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
2543
+ if (toolAlsoAllow)
2544
+ messages.push(toolAlsoAllow);
2545
+ return messages;
2277
2546
  }
2278
2547
 
2279
2548
  /**
2280
- * Factory for platform-aware service lifecycle management.
2549
+ * Plugin resolution helpers for the OpenClaw plugin SDK.
2281
2550
  *
2282
2551
  * @remarks
2283
- * Produces a `ServiceManager` that handles install, uninstall, start,
2284
- * stop, restart, and status for system services. Delegates to NSSM
2285
- * (Windows), systemd (Linux), or launchd (macOS) based on platform.
2552
+ * Provides workspace path resolution and plugin setting resolution
2553
+ * with a standard three-step fallback chain:
2554
+ * plugin config → environment variable → default value.
2286
2555
  */
2287
- /** Exec helper that returns stdout. */
2288
- function run$1(cmd) {
2289
- return execSync(cmd, {
2290
- encoding: 'utf-8',
2291
- timeout: 30_000,
2292
- stdio: ['pipe', 'pipe', 'pipe'],
2293
- }).trim();
2294
- }
2295
- /** Exec helper that suppresses errors and returns success boolean. */
2296
- function runQuiet(cmd) {
2297
- try {
2298
- execSync(cmd, {
2299
- encoding: 'utf-8',
2300
- timeout: 30_000,
2301
- stdio: ['pipe', 'pipe', 'pipe'],
2302
- });
2303
- return true;
2556
+ /**
2557
+ * Resolve the workspace root from the OpenClaw plugin API.
2558
+ *
2559
+ * @remarks
2560
+ * Tries three sources in order:
2561
+ * 1. `api.config.agents.defaults.workspace` — explicit config
2562
+ * 2. `api.resolvePath('.')` — gateway-provided path resolver
2563
+ * 3. `process.cwd()` — last resort
2564
+ *
2565
+ * @param api - The plugin API object provided by the gateway.
2566
+ * @returns Absolute path to the workspace root.
2567
+ */
2568
+ function resolveWorkspacePath(api) {
2569
+ const configured = api.config?.agents?.defaults?.workspace;
2570
+ if (typeof configured === 'string' && configured.trim()) {
2571
+ return configured;
2304
2572
  }
2305
- catch {
2306
- return false;
2573
+ if (typeof api.resolvePath === 'function') {
2574
+ return api.resolvePath('.');
2307
2575
  }
2576
+ return process.cwd();
2308
2577
  }
2309
2578
  /**
2310
- * Resolve the effective service name from options and descriptor.
2579
+ * Resolve a plugin setting via the standard three-step fallback chain:
2580
+ * plugin config → environment variable → fallback value.
2311
2581
  *
2312
- * @param descriptor - Component descriptor.
2313
- * @param options - Optional overrides.
2314
- * @returns The service name to use.
2582
+ * @param api - Plugin API object.
2583
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
2584
+ * @param key - Config key within the plugin's config object.
2585
+ * @param envVar - Environment variable name.
2586
+ * @param fallback - Default value if neither source provides one.
2587
+ * @returns The resolved setting value.
2315
2588
  */
2316
- function resolveServiceName(descriptor, options) {
2317
- return options?.name ?? getEffectiveServiceName(descriptor);
2589
+ function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
2590
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
2591
+ if (typeof fromPlugin === 'string')
2592
+ return fromPlugin;
2593
+ const fromEnv = process.env[envVar];
2594
+ if (fromEnv)
2595
+ return fromEnv;
2596
+ return fallback;
2318
2597
  }
2319
2598
  /**
2320
- * Resolve the config path for install.
2599
+ * Resolve an optional plugin setting via the two-step fallback chain:
2600
+ * plugin config → environment variable. Returns `undefined` if neither
2601
+ * source provides a value.
2321
2602
  *
2322
- * @param descriptor - Component descriptor.
2323
- * @param options - Optional overrides.
2324
- * @returns Absolute config file path.
2603
+ * @param api - Plugin API object.
2604
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
2605
+ * @param key - Config key within the plugin's config object.
2606
+ * @param envVar - Environment variable name.
2607
+ * @returns The resolved setting value, or `undefined`.
2325
2608
  */
2326
- function resolveConfigFilePath(descriptor, options) {
2327
- if (options?.configPath)
2328
- return options.configPath;
2329
- const configDir = getComponentConfigDir(descriptor.name);
2330
- return join(configDir, descriptor.configFileName);
2331
- }
2332
- /** Build a Windows NSSM service manager. */
2333
- function createWindowsManager(descriptor) {
2334
- return {
2335
- install(options) {
2336
- const svcName = resolveServiceName(descriptor, options);
2337
- const cfgPath = resolveConfigFilePath(descriptor, options);
2338
- const cmdArgs = descriptor.startCommand(cfgPath);
2339
- const appPath = cmdArgs[0];
2340
- const appArgs = cmdArgs.slice(1).join(' ');
2341
- run$1(`nssm install ${svcName} ${appPath}`);
2342
- if (appArgs) {
2343
- run$1(`nssm set ${svcName} AppParameters ${appArgs}`);
2344
- }
2345
- run$1(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
2346
- run$1(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
2347
- run$1(`nssm set ${svcName} AppRotateFiles 1`);
2348
- run$1(`nssm set ${svcName} AppRotateBytes 1048576`);
2349
- },
2350
- uninstall(options) {
2351
- const svcName = resolveServiceName(descriptor, options);
2352
- runQuiet(`nssm stop ${svcName}`);
2353
- run$1(`nssm remove ${svcName} confirm`);
2354
- },
2355
- start(options) {
2356
- const svcName = resolveServiceName(descriptor, options);
2357
- run$1(`nssm start ${svcName}`);
2358
- },
2359
- stop(options) {
2360
- const svcName = resolveServiceName(descriptor, options);
2361
- run$1(`nssm stop ${svcName}`);
2362
- },
2363
- restart(options) {
2364
- const svcName = resolveServiceName(descriptor, options);
2365
- run$1(`nssm restart ${svcName}`);
2366
- },
2367
- status(options) {
2368
- const svcName = resolveServiceName(descriptor, options);
2369
- return getServiceState(svcName);
2370
- },
2371
- };
2609
+ function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
2610
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
2611
+ if (typeof fromPlugin === 'string')
2612
+ return fromPlugin;
2613
+ const fromEnv = process.env[envVar];
2614
+ if (fromEnv)
2615
+ return fromEnv;
2616
+ return undefined;
2372
2617
  }
2618
+
2373
2619
  /**
2374
- * Generate a systemd user unit file.
2620
+ * Internal helpers for the plugin installer CLI.
2375
2621
  *
2376
- * @param svcName - Service name.
2377
- * @param cmdArgs - Command + args array.
2378
- * @returns Unit file content.
2622
+ * @module
2379
2623
  */
2380
- function buildSystemdUnit(svcName, cmdArgs) {
2381
- const execStart = cmdArgs.join(' ');
2382
- return [
2383
- '[Unit]',
2384
- `Description=${svcName}`,
2385
- 'After=network.target',
2386
- '',
2387
- '[Service]',
2388
- 'Type=simple',
2389
- `ExecStart=${execStart}`,
2390
- 'Restart=on-failure',
2391
- 'RestartSec=5',
2392
- '',
2393
- '[Install]',
2394
- 'WantedBy=default.target',
2395
- ].join('\n');
2624
+ /**
2625
+ * Derive a component name from a plugin ID.
2626
+ *
2627
+ * @remarks
2628
+ * Strips `jeeves-` prefix and `-openclaw` suffix.
2629
+ *
2630
+ * @param pluginId - The plugin identifier.
2631
+ * @returns Component short name.
2632
+ */
2633
+ function deriveComponentName(pluginId) {
2634
+ return pluginId.replace(/^jeeves-/, '').replace(/-openclaw$/, '');
2396
2635
  }
2397
- /** Build a Linux systemd service manager. */
2398
- function createLinuxManager(descriptor) {
2399
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
2400
- function unitPath(svcName) {
2401
- return join(unitDir, `${svcName}.service`);
2402
- }
2403
- return {
2404
- install(options) {
2405
- const svcName = resolveServiceName(descriptor, options);
2406
- const cfgPath = resolveConfigFilePath(descriptor, options);
2407
- const cmdArgs = descriptor.startCommand(cfgPath);
2408
- mkdirSync(unitDir, { recursive: true });
2409
- writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
2410
- run$1('systemctl --user daemon-reload');
2411
- run$1(`systemctl --user enable ${svcName}.service`);
2412
- },
2413
- uninstall(options) {
2414
- const svcName = resolveServiceName(descriptor, options);
2415
- runQuiet(`systemctl --user stop ${svcName}.service`);
2416
- runQuiet(`systemctl --user disable ${svcName}.service`);
2417
- const path = unitPath(svcName);
2418
- if (existsSync(path))
2419
- unlinkSync(path);
2420
- runQuiet('systemctl --user daemon-reload');
2421
- },
2422
- start(options) {
2423
- const svcName = resolveServiceName(descriptor, options);
2424
- run$1(`systemctl --user start ${svcName}.service`);
2425
- },
2426
- stop(options) {
2427
- const svcName = resolveServiceName(descriptor, options);
2428
- run$1(`systemctl --user stop ${svcName}.service`);
2429
- },
2430
- restart(options) {
2431
- const svcName = resolveServiceName(descriptor, options);
2432
- run$1(`systemctl --user restart ${svcName}.service`);
2433
- },
2434
- status(options) {
2435
- const svcName = resolveServiceName(descriptor, options);
2436
- return getServiceState(svcName);
2437
- },
2438
- };
2636
+ /**
2637
+ * Copy all files from source directory to destination, recursively.
2638
+ *
2639
+ * @param srcDir - Source directory.
2640
+ * @param destDir - Destination directory.
2641
+ */
2642
+ function copyDistFiles(srcDir, destDir) {
2643
+ mkdirSync(destDir, { recursive: true });
2644
+ const entries = readdirSync(srcDir, { withFileTypes: true });
2645
+ for (const entry of entries) {
2646
+ const srcPath = join(srcDir, entry.name);
2647
+ const destPath = join(destDir, entry.name);
2648
+ if (entry.isDirectory()) {
2649
+ copyDistFiles(srcPath, destPath);
2650
+ }
2651
+ else {
2652
+ copyFileSync(srcPath, destPath);
2653
+ }
2654
+ }
2439
2655
  }
2440
2656
  /**
2441
- * Generate a macOS launchd plist.
2657
+ * Read and parse a JSON file, returning an empty object if not found.
2442
2658
  *
2443
- * @param svcName - Service label.
2444
- * @param cmdArgs - Command + args array.
2445
- * @returns Plist XML content.
2659
+ * @param filePath - Path to the JSON file.
2660
+ * @returns Parsed object.
2446
2661
  */
2447
- function buildLaunchdPlist(svcName, cmdArgs) {
2448
- const argsXml = cmdArgs.map((a) => ` <string>${a}</string>`).join('\n');
2449
- return [
2450
- '<?xml version="1.0" encoding="UTF-8"?>',
2451
- '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"',
2452
- ' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
2453
- '<plist version="1.0">',
2454
- '<dict>',
2455
- ' <key>Label</key>',
2456
- ` <string>${svcName}</string>`,
2457
- ' <key>ProgramArguments</key>',
2458
- ' <array>',
2459
- argsXml,
2460
- ' </array>',
2461
- ' <key>RunAtLoad</key>',
2462
- ' <true/>',
2463
- ' <key>KeepAlive</key>',
2464
- ' <true/>',
2465
- ' <key>StandardOutPath</key>',
2466
- ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
2467
- ' <key>StandardErrorPath</key>',
2468
- ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
2469
- '</dict>',
2470
- '</plist>',
2471
- ].join('\n');
2472
- }
2473
- /** Build a macOS launchd service manager. */
2474
- function createMacOSManager(descriptor) {
2475
- const agentsDir = join(homedir(), 'Library', 'LaunchAgents');
2476
- function plistPath(svcName) {
2477
- return join(agentsDir, `${svcName}.plist`);
2662
+ function readJsonFile(filePath) {
2663
+ try {
2664
+ const raw = readFileSync(filePath, 'utf-8');
2665
+ return JSON.parse(raw);
2666
+ }
2667
+ catch {
2668
+ return {};
2478
2669
  }
2479
- return {
2480
- install(options) {
2481
- const svcName = resolveServiceName(descriptor, options);
2482
- const cfgPath = resolveConfigFilePath(descriptor, options);
2483
- const cmdArgs = descriptor.startCommand(cfgPath);
2484
- mkdirSync(agentsDir, { recursive: true });
2485
- writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
2486
- },
2487
- uninstall(options) {
2488
- const svcName = resolveServiceName(descriptor, options);
2489
- runQuiet(`launchctl unload ${plistPath(svcName)}`);
2490
- const path = plistPath(svcName);
2491
- if (existsSync(path))
2492
- unlinkSync(path);
2493
- },
2494
- start(options) {
2495
- const svcName = resolveServiceName(descriptor, options);
2496
- run$1(`launchctl load ${plistPath(svcName)}`);
2497
- },
2498
- stop(options) {
2499
- const svcName = resolveServiceName(descriptor, options);
2500
- run$1(`launchctl unload ${plistPath(svcName)}`);
2501
- },
2502
- restart(options) {
2503
- const svcName = resolveServiceName(descriptor, options);
2504
- runQuiet(`launchctl unload ${plistPath(svcName)}`);
2505
- run$1(`launchctl load ${plistPath(svcName)}`);
2506
- },
2507
- status(options) {
2508
- const svcName = resolveServiceName(descriptor, options);
2509
- return getServiceState(svcName);
2510
- },
2511
- };
2512
2670
  }
2671
+
2513
2672
  /**
2514
- * Create a platform-aware service manager from a component descriptor.
2673
+ * Factory for the standard `-openclaw` plugin installer CLI.
2515
2674
  *
2516
- * @remarks
2517
- * Detects the current platform and returns a `ServiceManager` that
2518
- * delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
2675
+ * @module
2676
+ */
2677
+ /**
2678
+ * Create a standard plugin installer CLI program.
2519
2679
  *
2520
- * @param descriptor - The component descriptor.
2521
- * @returns A `ServiceManager` for the current platform.
2680
+ * @param options - Plugin CLI configuration.
2681
+ * @returns A Commander program ready for `.parse()`.
2522
2682
  */
2523
- function createServiceManager(descriptor) {
2524
- switch (process.platform) {
2525
- case 'win32':
2526
- return createWindowsManager(descriptor);
2527
- case 'darwin':
2528
- return createMacOSManager(descriptor);
2529
- default:
2530
- return createLinuxManager(descriptor);
2683
+ function createPluginCli(options) {
2684
+ const { pluginId, importMetaUrl, pluginPackage, configRoot = 'j:/config', } = options;
2685
+ const componentName = options.componentName ?? deriveComponentName(pluginId);
2686
+ const pkgRoot = getPackageRoot(importMetaUrl);
2687
+ if (!pkgRoot) {
2688
+ throw new Error(`Unable to resolve package root for plugin CLI: ${pluginPackage}`);
2531
2689
  }
2690
+ const distDir = join(pkgRoot, 'dist');
2691
+ const program = new Command()
2692
+ .name(pluginPackage)
2693
+ .description(`Jeeves ${componentName} plugin installer`);
2694
+ program
2695
+ .command('install')
2696
+ .description(`Install the ${componentName} plugin`)
2697
+ .option('--memory', 'Claim a memory slot for this plugin')
2698
+ .option('-w, --workspace <path>', 'Workspace root path')
2699
+ .option('-c, --config-root <path>', 'Platform config root path', configRoot)
2700
+ .action((opts) => {
2701
+ const openClawHome = resolveOpenClawHome();
2702
+ const configPath = resolveConfigPath(openClawHome);
2703
+ // 1. Copy dist to extensions
2704
+ const extensionsDir = join(openClawHome, 'extensions', pluginId);
2705
+ if (!existsSync(distDir)) {
2706
+ throw new Error(`Plugin dist directory not found: ${distDir}. Ensure the plugin is built before installing.`);
2707
+ }
2708
+ console.log(`Copying dist to ${extensionsDir}...`);
2709
+ copyDistFiles(distDir, extensionsDir);
2710
+ // Copy package.json and openclaw.plugin.json from package root
2711
+ for (const file of ['package.json', 'openclaw.plugin.json']) {
2712
+ const src = join(pkgRoot, file);
2713
+ if (existsSync(src)) {
2714
+ copyFileSync(src, join(extensionsDir, file));
2715
+ }
2716
+ }
2717
+ console.log(' ✓ Dist files copied');
2718
+ // 2. Patch openclaw.json
2719
+ console.log('Patching OpenClaw config...');
2720
+ const config = readJsonFile(configPath);
2721
+ const pkgJsonPathForVersion = join(extensionsDir, 'package.json');
2722
+ let pluginVersionForRecord;
2723
+ try {
2724
+ const pkgJsonForRecord = readJsonFile(pkgJsonPathForVersion);
2725
+ pluginVersionForRecord =
2726
+ typeof pkgJsonForRecord.version === 'string'
2727
+ ? pkgJsonForRecord.version
2728
+ : undefined;
2729
+ }
2730
+ catch {
2731
+ // best-effort: version may not be available yet
2732
+ }
2733
+ const messages = patchConfig(config, pluginId, 'add', {
2734
+ installPath: extensionsDir,
2735
+ version: pluginVersionForRecord,
2736
+ });
2737
+ // 3. Memory slot claim
2738
+ if (opts.memory) {
2739
+ if (!config.agents || typeof config.agents !== 'object') {
2740
+ config.agents = {};
2741
+ }
2742
+ const agents = config.agents;
2743
+ if (!agents.defaults || typeof agents.defaults !== 'object') {
2744
+ agents.defaults = {};
2745
+ }
2746
+ const defaults = agents.defaults;
2747
+ if (!defaults.memory || typeof defaults.memory !== 'object') {
2748
+ defaults.memory = {};
2749
+ }
2750
+ const memory = defaults.memory;
2751
+ if (!memory[componentName]) {
2752
+ memory[componentName] = {};
2753
+ messages.push(`Claimed memory slot for "${componentName}"`);
2754
+ }
2755
+ }
2756
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
2757
+ for (const msg of messages) {
2758
+ console.log(` ✓ ${msg}`);
2759
+ }
2760
+ // 4. Write initial HEARTBEAT entry and seed jeeves skill
2761
+ try {
2762
+ const cfgRoot = opts.configRoot;
2763
+ const agents = config.agents;
2764
+ const defaults = agents?.defaults;
2765
+ const ws = opts.workspace ?? defaults?.workspace;
2766
+ if (ws) {
2767
+ init({ workspacePath: ws, configRoot: cfgRoot });
2768
+ const heartbeatPath = join(ws, WORKSPACE_FILES.heartbeat);
2769
+ try {
2770
+ const existing = existsSync(heartbeatPath)
2771
+ ? readFileSync(heartbeatPath, 'utf-8')
2772
+ : '';
2773
+ const parsed = parseHeartbeat(existing);
2774
+ const fullName = `jeeves-${componentName}`;
2775
+ const hasEntry = parsed.entries.some((e) => e.name === fullName);
2776
+ if (!hasEntry) {
2777
+ parsed.entries.push({
2778
+ name: fullName,
2779
+ declined: false,
2780
+ content: `- Plugin installed. Awaiting service configuration.`,
2781
+ });
2782
+ const section = buildHeartbeatSection(parsed.entries);
2783
+ atomicWrite(heartbeatPath, section);
2784
+ console.log(' ✓ HEARTBEAT entry written');
2785
+ }
2786
+ }
2787
+ catch {
2788
+ console.log(' ⚠ Could not write HEARTBEAT entry');
2789
+ }
2790
+ try {
2791
+ seedSkill(ws);
2792
+ console.log(' ✓ Jeeves skill seeded');
2793
+ }
2794
+ catch {
2795
+ console.log(' ⚠ Could not seed Jeeves skill');
2796
+ }
2797
+ }
2798
+ }
2799
+ catch {
2800
+ // HEARTBEAT + skill seeding are best-effort during install
2801
+ }
2802
+ // 5. Write component version
2803
+ try {
2804
+ init({
2805
+ workspacePath: opts.workspace ?? '.',
2806
+ configRoot: opts.configRoot,
2807
+ });
2808
+ const pkgJsonPath = join(extensionsDir, 'package.json');
2809
+ const pkgJson = readJsonFile(pkgJsonPath);
2810
+ const pluginVersion = typeof pkgJson.version === 'string' ? pkgJson.version : undefined;
2811
+ writeComponentVersion(getCoreConfigDir(), {
2812
+ componentName,
2813
+ pluginPackage,
2814
+ pluginVersion,
2815
+ });
2816
+ console.log(' ✓ Component version written');
2817
+ }
2818
+ catch {
2819
+ console.log(' ⚠ Could not write component version');
2820
+ }
2821
+ console.log();
2822
+ console.log(`✅ ${pluginPackage} installed.`);
2823
+ });
2824
+ program
2825
+ .command('uninstall')
2826
+ .description(`Uninstall the ${componentName} plugin`)
2827
+ .option('-w, --workspace <path>', 'Workspace root path')
2828
+ .option('-c, --config-root <path>', 'Platform config root path', configRoot)
2829
+ .action(async (opts) => {
2830
+ const openClawHome = resolveOpenClawHome();
2831
+ const cfgPath = resolveConfigPath(openClawHome);
2832
+ // 1. Remove from extensions
2833
+ const extensionsDir = join(openClawHome, 'extensions', pluginId);
2834
+ if (existsSync(extensionsDir)) {
2835
+ rmSync(extensionsDir, { recursive: true, force: true });
2836
+ console.log(' ✓ Extension files removed');
2837
+ }
2838
+ // 2. Unpatch openclaw.json
2839
+ if (existsSync(cfgPath)) {
2840
+ const config = readJsonFile(cfgPath);
2841
+ const messages = patchConfig(config, pluginId, 'remove');
2842
+ writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n');
2843
+ for (const msg of messages) {
2844
+ console.log(` ✓ ${msg}`);
2845
+ }
2846
+ }
2847
+ // 3. Remove TOOLS.md section
2848
+ try {
2849
+ const ws = opts.workspace;
2850
+ if (ws) {
2851
+ init({ workspacePath: ws, configRoot: opts.configRoot });
2852
+ const sectionId = componentName.charAt(0).toUpperCase() + componentName.slice(1);
2853
+ const toolsPath = join(ws, WORKSPACE_FILES.tools);
2854
+ if (existsSync(toolsPath)) {
2855
+ await removeManagedSection(toolsPath, {
2856
+ sectionId,
2857
+ markers: TOOLS_MARKERS,
2858
+ });
2859
+ console.log(' ✓ TOOLS.md section removed');
2860
+ }
2861
+ }
2862
+ }
2863
+ catch {
2864
+ console.log(' ⚠ Could not remove TOOLS.md section');
2865
+ }
2866
+ // 4. Remove component-versions.json entry
2867
+ try {
2868
+ init({
2869
+ workspacePath: opts.workspace ?? '.',
2870
+ configRoot: opts.configRoot,
2871
+ });
2872
+ removeComponentVersion(getCoreConfigDir(), componentName);
2873
+ console.log(' ✓ Component version entry removed');
2874
+ }
2875
+ catch {
2876
+ console.log(' ⚠ Could not remove component version entry');
2877
+ }
2878
+ console.log();
2879
+ console.log(`✅ ${pluginPackage} uninstalled.`);
2880
+ });
2881
+ return program;
2532
2882
  }
2533
2883
 
2534
2884
  /**
@@ -3314,1624 +3664,1298 @@ When editing files outside the workspace, use the bridge pattern: copy in → ed
3314
3664
 
3315
3665
  ### Plugin Lifecycle
3316
3666
 
3317
- \`\`\`bash
3318
- # Platform bootstrap (content seeding)
3319
- npx @karmaniverous/jeeves install
3320
-
3321
- # Component plugin install
3322
- npx @karmaniverous/jeeves-{component}-openclaw install
3323
-
3324
- # Component plugin uninstall
3325
- npx @karmaniverous/jeeves-{component}-openclaw uninstall
3326
-
3327
- # Platform teardown (remove managed sections)
3328
- npx @karmaniverous/jeeves uninstall
3329
- \`\`\`
3330
-
3331
- Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
3332
-
3333
- ### Reference Templates
3334
-
3335
- <!-- IF_TEMPLATES -->
3336
- Reference templates are available at \`__TEMPLATE_PATH__\`:
3337
-
3338
- | Template | Purpose |
3339
- |----------|---------|
3340
- | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
3341
- | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
3342
-
3343
- Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
3344
- <!-- ELSE_TEMPLATES -->
3345
- > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
3346
- <!-- ENDIF_TEMPLATES -->
3347
- `;
3348
-
3349
- /**
3350
- * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
3351
- *
3352
- * @remarks
3353
- * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
3354
- * Reads content files from the package's `content/` directory, renders the
3355
- * Platform template with live data, and writes managed sections using
3356
- * `updateManagedSection`.
3357
- */
3358
- /**
3359
- * Resolve the package's content directory for template file copying.
3360
- *
3361
- * @remarks
3362
- * Templates are actual files that need to be copied to the config directory.
3363
- * This only works when core is in `node_modules` (CLI install, service).
3364
- * When bundled into a consumer plugin, returns undefined and template
3365
- * copying is skipped (templates are seeded by `jeeves install`, not plugins).
3366
- *
3367
- * Content `.md` files (soul, agents, platform template) are inlined at
3368
- * build time via the rollup md plugin and imported as string literals.
3369
- * They do not use this function.
3370
- *
3371
- * @returns Absolute path to the content/ directory, or undefined.
3372
- */
3373
- function getContentDir() {
3374
- const pkgDir = packageDirectorySync({
3375
- cwd: fileURLToPath(import.meta.url),
3376
- });
3377
- if (!pkgDir)
3378
- return undefined;
3379
- const dir = join(pkgDir, 'content');
3380
- return existsSync(dir) ? dir : undefined;
3381
- }
3382
- /**
3383
- * Copy templates from content/templates/ to the core config directory.
3384
- *
3385
- * @param coreConfigDir - Core config directory path.
3386
- */
3387
- function copyTemplates(coreConfigDir) {
3388
- const contentDir = getContentDir();
3389
- if (!contentDir)
3390
- return;
3391
- const sourceDir = join(contentDir, 'templates');
3392
- if (!existsSync(sourceDir))
3393
- return;
3394
- const destDir = join(coreConfigDir, TEMPLATES_DIR);
3395
- if (!existsSync(destDir)) {
3396
- mkdirSync(destDir, { recursive: true });
3397
- }
3398
- cpSync(sourceDir, destDir, { recursive: true });
3399
- }
3400
- /**
3401
- * Render the Platform template using simple string replacement.
3402
- *
3403
- * @param templatePath - Path to the templates directory.
3404
- * @returns Rendered platform content string.
3405
- */
3406
- function renderPlatformTemplate(templatePath) {
3407
- const templatesAvailable = existsSync(templatePath);
3408
- let content = toolsPlatformTemplate;
3409
- // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
3410
- const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
3411
- const match = ifRegex.exec(content);
3412
- if (match) {
3413
- content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
3414
- }
3415
- // Replace __TEMPLATE_PATH__ with the actual path
3416
- content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
3417
- return content;
3418
- }
3419
- /**
3420
- * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
3421
- *
3422
- * @param options - Configuration for the refresh cycle.
3423
- */
3424
- async function refreshPlatformContent(options) {
3425
- const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
3426
- const workspacePath = getWorkspacePath();
3427
- const coreConfigDir = getCoreConfigDir();
3428
- // 1. Write calling component's version entry
3429
- if (componentName) {
3430
- writeComponentVersion(coreConfigDir, {
3431
- componentName,
3432
- pluginVersion: componentVersion,
3433
- servicePackage,
3434
- pluginPackage,
3435
- });
3436
- }
3437
- // 2. Render Platform template
3438
- const templatePath = join(coreConfigDir, TEMPLATES_DIR);
3439
- const platformContent = renderPlatformTemplate(templatePath);
3440
- // 3. Write TOOLS.md Platform section
3441
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
3442
- await updateManagedSection(toolsPath, platformContent, {
3443
- mode: 'section',
3444
- sectionId: 'Platform',
3445
- markers: TOOLS_MARKERS,
3446
- coreVersion,
3447
- stalenessThresholdMs,
3448
- });
3449
- // 4. Write SOUL.md managed block
3450
- const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
3451
- await updateManagedSection(soulPath, soulSectionContent, {
3452
- mode: 'block',
3453
- markers: SOUL_MARKERS,
3454
- coreVersion,
3455
- stalenessThresholdMs,
3456
- });
3457
- // 5. Write AGENTS.md managed block
3458
- const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
3459
- await updateManagedSection(agentsPath, agentsSectionContent, {
3460
- mode: 'block',
3461
- markers: AGENTS_MARKERS,
3462
- coreVersion,
3463
- stalenessThresholdMs,
3464
- });
3465
- // 6. Copy templates to config dir
3466
- copyTemplates(coreConfigDir);
3467
- }
3468
-
3469
- /**
3470
- * Cleanup-session escalation for managed files with orphaned duplicated content.
3471
- *
3472
- * @remarks
3473
- * When a managed file contains the cleanup flag, the writer can ask the
3474
- * OpenClaw gateway to spawn a background session to remove orphaned content.
3475
- * The request is best-effort: accepted requests return `true`; any transport
3476
- * or HTTP failure returns `false` so the file warning remains the fallback.
3477
- */
3478
- /** Timeout for cleanup-session spawn requests. */
3479
- const CLEANUP_REQUEST_TIMEOUT_MS = 5_000;
3480
- /**
3481
- * Build the cleanup task prompt sent to the gateway session API.
3482
- *
3483
- * @param filePath - Managed file requiring cleanup.
3484
- * @param markerIdentity - Marker identity for the file.
3485
- * @returns Cleanup instructions for the spawned session.
3486
- */
3487
- function buildCleanupTask(filePath, markerIdentity) {
3488
- return [
3489
- `Clean up orphaned managed content in ${filePath}.`,
3490
- `The file uses ${markerIdentity} managed comment markers.`,
3491
- 'Review content outside the managed block and remove only duplicated managed content.',
3492
- 'Preserve any unique user-authored content outside the managed block.',
3493
- 'Do not modify content inside the managed block unless required to preserve valid marker structure.',
3494
- ].join(' ');
3495
- }
3496
- /**
3497
- * Request a cleanup session from the OpenClaw gateway.
3498
- *
3499
- * @remarks
3500
- * Fire-and-forget. A 200-class response means the request was accepted.
3501
- * Any HTTP or transport failure returns `false` so the file-level cleanup
3502
- * warning remains the only signal.
3503
- *
3504
- * @param options - Cleanup request configuration.
3505
- * @returns Whether the gateway accepted the cleanup request.
3506
- */
3507
- async function requestCleanupSession(options) {
3508
- const { gatewayUrl, filePath, markerIdentity } = options;
3509
- const url = `${gatewayUrl.replace(/\/$/, '')}/sessions/spawn`;
3510
- const label = `cleanup:${basename(filePath)}`;
3511
- const body = {
3512
- task: buildCleanupTask(filePath, markerIdentity),
3513
- label,
3514
- };
3515
- try {
3516
- const response = await fetchWithTimeout(url, CLEANUP_REQUEST_TIMEOUT_MS, {
3517
- method: 'POST',
3518
- headers: { 'Content-Type': 'application/json' },
3519
- body: JSON.stringify(body),
3520
- });
3521
- return response.ok;
3522
- }
3523
- catch {
3524
- return false;
3525
- }
3526
- }
3667
+ \`\`\`bash
3668
+ # Platform bootstrap (content seeding)
3669
+ npx @karmaniverous/jeeves install
3527
3670
 
3528
- /**
3529
- * Cleanup flag scanning extracted from ComponentWriter.cycle().
3530
- *
3531
- * @remarks
3532
- * After writing managed files, scans each for the cleanup flag and
3533
- * fires a best-effort escalation request when a gateway URL is configured.
3534
- * Uses a `pendingCleanups` set to deduplicate in-flight requests.
3535
- */
3536
- /**
3537
- * Scan managed files for the cleanup flag and escalate when detected.
3538
- *
3539
- * @param targets - Managed files to scan.
3540
- * @param gatewayUrl - Gateway URL for session spawn.
3541
- * @param pendingCleanups - Set tracking in-flight requests (mutated).
3542
- */
3543
- function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
3544
- for (const target of targets) {
3545
- try {
3546
- if (pendingCleanups.has(target.filePath))
3547
- continue;
3548
- const fileContent = readFileSync(target.filePath, 'utf-8');
3549
- if (fileContent.includes(CLEANUP_FLAG)) {
3550
- pendingCleanups.add(target.filePath);
3551
- void requestCleanupSession({
3552
- gatewayUrl,
3553
- filePath: target.filePath,
3554
- markerIdentity: target.markerIdentity,
3555
- }).finally(() => {
3556
- pendingCleanups.delete(target.filePath);
3557
- });
3558
- }
3559
- }
3560
- catch {
3561
- // Best-effort: don't fail the cycle for escalation issues.
3562
- }
3563
- }
3564
- }
3671
+ # Component plugin install
3672
+ npx @karmaniverous/jeeves-{component}-openclaw install
3565
3673
 
3566
- /**
3567
- * Memory budget accounting and staleness detection for MEMORY.md.
3568
- *
3569
- * @remarks
3570
- * Scans MEMORY.md for ISO date patterns in H2/H3 headings and bullet items.
3571
- * Reports character count against a configured budget, warning threshold state,
3572
- * and stale section candidates. Does not auto-delete: review remains
3573
- * human- or agent-mediated (Decision 42).
3574
- */
3575
- /** ISO date pattern: YYYY-MM-DD. */
3576
- const ISO_DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
3577
- /** H2 heading pattern used to split sections. */
3578
- const H2_RE = /^## /m;
3579
- /**
3580
- * Extract the most recent ISO date from a string.
3581
- *
3582
- * @param text - Text to scan for dates.
3583
- * @returns The most recent date found, or undefined.
3584
- */
3585
- function extractMostRecentDate(text) {
3586
- const matches = text.match(ISO_DATE_RE);
3587
- if (!matches)
3588
- return undefined;
3589
- let latest;
3590
- for (const match of matches) {
3591
- const d = new Date(match + 'T00:00:00Z');
3592
- if (!Number.isNaN(d.getTime())) {
3593
- if (!latest || d > latest)
3594
- latest = d;
3595
- }
3596
- }
3597
- return latest;
3598
- }
3599
- /**
3600
- * Analyze MEMORY.md for budget and staleness.
3601
- *
3602
- * @param options - Analysis configuration.
3603
- * @returns Memory hygiene result.
3604
- */
3605
- function analyzeMemory(options) {
3606
- const { workspacePath, budget, warningThreshold, staleDays } = options;
3607
- const memoryPath = join(workspacePath, WORKSPACE_FILES.memory);
3608
- if (!existsSync(memoryPath)) {
3609
- return {
3610
- exists: false,
3611
- charCount: 0,
3612
- budget,
3613
- usage: 0,
3614
- warning: false,
3615
- overBudget: false,
3616
- staleCandidates: 0,
3617
- staleSectionNames: [],
3618
- };
3619
- }
3620
- const content = readFileSync(memoryPath, 'utf-8');
3621
- const charCount = content.length;
3622
- const usage = budget > 0 ? charCount / budget : charCount > 0 ? Infinity : 0;
3623
- const warning = usage >= warningThreshold;
3624
- const overBudget = usage > 1;
3625
- // Split into H2 sections and scan for staleness
3626
- const sections = content.split(H2_RE).slice(1); // skip content before first H2
3627
- const now = Date.now();
3628
- const thresholdMs = staleDays * 24 * 60 * 60 * 1000;
3629
- const staleSectionNames = [];
3630
- for (const section of sections) {
3631
- const sectionName = section.split('\n')[0]?.trim() ?? '';
3632
- const recentDate = extractMostRecentDate(section);
3633
- // Sections without dates are evergreen — never flagged (Decision 47)
3634
- if (!recentDate)
3635
- continue;
3636
- if (now - recentDate.getTime() > thresholdMs) {
3637
- staleSectionNames.push(sectionName);
3638
- }
3639
- }
3640
- return {
3641
- exists: true,
3642
- charCount,
3643
- budget,
3644
- usage,
3645
- warning,
3646
- overBudget,
3647
- staleCandidates: staleSectionNames.length,
3648
- staleSectionNames,
3649
- };
3650
- }
3674
+ # Component plugin uninstall
3675
+ npx @karmaniverous/jeeves-{component}-openclaw uninstall
3676
+
3677
+ # Platform teardown (remove managed sections)
3678
+ npx @karmaniverous/jeeves uninstall
3679
+ \`\`\`
3680
+
3681
+ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
3682
+
3683
+ ### Reference Templates
3684
+
3685
+ <!-- IF_TEMPLATES -->
3686
+ Reference templates are available at \`__TEMPLATE_PATH__\`:
3687
+
3688
+ | Template | Purpose |
3689
+ |----------|---------|
3690
+ | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
3691
+ | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
3692
+
3693
+ Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
3694
+ <!-- ELSE_TEMPLATES -->
3695
+ > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
3696
+ <!-- ENDIF_TEMPLATES -->
3697
+ `;
3651
3698
 
3652
3699
  /**
3653
- * HEARTBEAT integration for memory hygiene.
3700
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
3654
3701
  *
3655
3702
  * @remarks
3656
- * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
3657
- * suitable for inclusion in the HEARTBEAT.md platform status section.
3658
- * Returns `undefined` when MEMORY.md is healthy (no alert needed).
3659
- *
3660
- * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
3661
- * alerts from component alerts (`## jeeves-{name}`).
3662
- */
3663
- /** The HEARTBEAT heading name for memory alerts. */
3664
- const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
3665
- /**
3666
- * Check memory health and return a HEARTBEAT entry if unhealthy.
3667
- *
3668
- * @param options - Memory hygiene options (workspacePath, budget, etc.).
3669
- * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
3703
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
3704
+ * Reads content files from the package's `content/` directory, renders the
3705
+ * Platform template with live data, and writes managed sections using
3706
+ * `updateManagedSection`.
3670
3707
  */
3671
- function checkMemoryHealth(options) {
3672
- const result = analyzeMemory(options);
3673
- if (!result.exists)
3674
- return undefined;
3675
- if (!result.warning && result.staleCandidates === 0)
3676
- return undefined;
3677
- const lines = [];
3678
- if (result.warning) {
3679
- const pct = Math.round(result.usage * 100);
3680
- lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
3681
- }
3682
- if (result.staleCandidates > 0) {
3683
- lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
3684
- }
3685
- return {
3686
- name: MEMORY_HEARTBEAT_NAME,
3687
- declined: false,
3688
- content: lines.join('\n'),
3689
- };
3690
- }
3691
-
3692
3708
  /**
3693
- * HEARTBEAT integration for workspace file size monitoring.
3709
+ * Resolve the package's content directory for template file copying.
3694
3710
  *
3695
3711
  * @remarks
3696
- * Checks all injected workspace files (AGENTS.md, SOUL.md, TOOLS.md,
3697
- * MEMORY.md, USER.md) against the OpenClaw ~20,000-char injection limit.
3698
- * Files exceeding the warning threshold generate HEARTBEAT entries with
3699
- * trimming guidance.
3700
- */
3701
- /** Workspace files monitored for size budget. */
3702
- const WORKSPACE_SIZE_FILES = [
3703
- 'AGENTS.md',
3704
- 'SOUL.md',
3705
- 'TOOLS.md',
3706
- 'MEMORY.md',
3707
- 'USER.md',
3708
- ];
3709
- /** Trimming guidance lines emitted in HEARTBEAT entries. */
3710
- const TRIMMING_GUIDANCE = [
3711
- ' 1. Move domain-specific content to a local skill',
3712
- ' 2. Extract reference material to companion files with a pointer',
3713
- ' 3. Summarize verbose instructions',
3714
- ' 4. Remove stale content',
3715
- ].join('\n');
3716
- /**
3717
- * Check all workspace files against the character budget.
3712
+ * Templates are actual files that need to be copied to the config directory.
3713
+ * This only works when core is in `node_modules` (CLI install, service).
3714
+ * When bundled into a consumer plugin, returns undefined and template
3715
+ * copying is skipped (templates are seeded by `jeeves install`, not plugins).
3718
3716
  *
3719
- * @param options - Health check options.
3720
- * @returns Array of results, one per checked file (skips non-existent files
3721
- * unless they breach the budget, which they cannot by definition).
3722
- */
3723
- function checkWorkspaceFileHealth(options) {
3724
- const { workspacePath, budgetChars = 20_000, warningThreshold = 0.8, } = options;
3725
- return WORKSPACE_SIZE_FILES.map((file) => {
3726
- const filePath = join(workspacePath, file);
3727
- if (!existsSync(filePath)) {
3728
- return {
3729
- file,
3730
- exists: false,
3731
- charCount: 0,
3732
- budget: budgetChars,
3733
- usage: 0,
3734
- warning: false,
3735
- overBudget: false,
3736
- };
3737
- }
3738
- const content = readFileSync(filePath, 'utf-8');
3739
- const charCount = content.length;
3740
- const usage = charCount / budgetChars;
3741
- return {
3742
- file,
3743
- exists: true,
3744
- charCount,
3745
- budget: budgetChars,
3746
- usage,
3747
- warning: usage >= warningThreshold,
3748
- overBudget: charCount > budgetChars,
3749
- };
3750
- });
3751
- }
3752
- /**
3753
- * Convert workspace file health results into HEARTBEAT entries.
3717
+ * Content `.md` files (soul, agents, platform template) are inlined at
3718
+ * build time via the rollup md plugin and imported as string literals.
3719
+ * They do not use this function.
3754
3720
  *
3755
- * @param results - Results from `checkWorkspaceFileHealth`.
3756
- * @returns Array of `HeartbeatEntry` objects for files that exceed the
3757
- * warning threshold.
3721
+ * @returns Absolute path to the content/ directory, or undefined.
3758
3722
  */
3759
- function workspaceFileHealthEntries(results) {
3760
- return results
3761
- .filter((r) => r.exists && r.warning)
3762
- .map((r) => {
3763
- const pct = Math.round(r.usage * 100);
3764
- const overBudgetNote = r.overBudget ? ' **Over budget.**' : '';
3765
- const content = [
3766
- `- Budget: ${r.charCount.toLocaleString()} / ${r.budget.toLocaleString()} chars (${String(pct)}%).${overBudgetNote} Trim to stay under the OpenClaw injection limit.`,
3767
- `- Suggested trimming priority:\n${TRIMMING_GUIDANCE}`,
3768
- ].join('\n');
3769
- return {
3770
- name: r.file,
3771
- declined: false,
3772
- content,
3773
- };
3723
+ function getContentDir() {
3724
+ const pkgDir = packageDirectorySync({
3725
+ cwd: fileURLToPath(import.meta.url),
3774
3726
  });
3727
+ if (!pkgDir)
3728
+ return undefined;
3729
+ const dir = join(pkgDir, 'content');
3730
+ return existsSync(dir) ? dir : undefined;
3775
3731
  }
3776
-
3777
3732
  /**
3778
- * Core configuration schema and resolution.
3733
+ * Copy templates from content/templates/ to the core config directory.
3779
3734
  *
3780
- * @remarks
3781
- * Core config lives at `{configRoot}/jeeves-core/config.json`.
3782
- * Config resolution order:
3783
- * 1. Component's own config file
3784
- * 2. Core config file
3785
- * 3. Hardcoded library defaults
3735
+ * @param coreConfigDir - Core config directory path.
3786
3736
  */
3787
- /** Zod schema for a service entry in core config. */
3788
- const serviceEntrySchema = z.object({
3789
- /** Service URL (must be a valid URL). */
3790
- url: z.url().describe('Service URL'),
3791
- });
3792
- /** Default bind address for all Jeeves services. */
3793
- const DEFAULT_BIND_ADDRESS = '0.0.0.0';
3794
- /** Zod schema for the core config file. */
3795
- const coreConfigSchema = z.object({
3796
- /** JSON Schema pointer for IDE autocomplete. */
3797
- $schema: z.string().optional().describe('JSON Schema pointer'),
3798
- /** Owner identity keys (canonical identityLinks references). */
3799
- owners: z.array(z.string()).default([]).describe('Owner identity keys'),
3800
- /**
3801
- * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
3802
- * Individual components can override in their own config.
3803
- */
3804
- bindAddress: z
3805
- .string()
3806
- .default(DEFAULT_BIND_ADDRESS)
3807
- .describe('Bind address for all Jeeves services'),
3808
- /** Service URL overrides keyed by service name. */
3809
- services: z
3810
- .record(z.string(), serviceEntrySchema)
3811
- .default({})
3812
- .describe('Service URL overrides'),
3813
- /** Registry cache configuration. */
3814
- registryCache: z
3815
- .object({
3816
- /** Cache TTL in seconds for npm registry queries. */
3817
- ttlSeconds: z
3818
- .number()
3819
- .int()
3820
- .positive()
3821
- .default(3600)
3822
- .describe('Cache TTL in seconds'),
3823
- })
3824
- .prefault({})
3825
- .describe('Registry cache settings'),
3826
- });
3737
+ function copyTemplates(coreConfigDir) {
3738
+ const contentDir = getContentDir();
3739
+ if (!contentDir)
3740
+ return;
3741
+ const sourceDir = join(contentDir, 'templates');
3742
+ if (!existsSync(sourceDir))
3743
+ return;
3744
+ const destDir = join(coreConfigDir, TEMPLATES_DIR);
3745
+ if (!existsSync(destDir)) {
3746
+ mkdirSync(destDir, { recursive: true });
3747
+ }
3748
+ cpSync(sourceDir, destDir, { recursive: true });
3749
+ }
3827
3750
  /**
3828
- * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
3751
+ * Render the Platform template using simple string replacement.
3829
3752
  *
3830
- * @returns A JSON Schema object.
3753
+ * @param templatePath - Path to the templates directory.
3754
+ * @returns Rendered platform content string.
3831
3755
  */
3832
- function generateJsonSchema() {
3833
- return {
3834
- $schema: 'http://json-schema.org/draft-07/schema#',
3835
- title: 'Jeeves Core Configuration',
3836
- type: 'object',
3837
- properties: {
3838
- $schema: { type: 'string' },
3839
- owners: {
3840
- type: 'array',
3841
- items: { type: 'string' },
3842
- default: [],
3843
- },
3844
- bindAddress: {
3845
- type: 'string',
3846
- default: '0.0.0.0',
3847
- description: 'Bind address for all Jeeves services',
3848
- },
3849
- services: {
3850
- type: 'object',
3851
- additionalProperties: {
3852
- type: 'object',
3853
- properties: {
3854
- url: { type: 'string', format: 'uri' },
3855
- },
3856
- required: ['url'],
3857
- },
3858
- default: {},
3859
- },
3860
- registryCache: {
3861
- type: 'object',
3862
- properties: {
3863
- ttlSeconds: {
3864
- type: 'integer',
3865
- minimum: 1,
3866
- default: 3600,
3867
- },
3868
- },
3869
- default: {},
3870
- },
3871
- },
3872
- };
3756
+ function renderPlatformTemplate(templatePath) {
3757
+ const templatesAvailable = existsSync(templatePath);
3758
+ let content = toolsPlatformTemplate;
3759
+ // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
3760
+ const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
3761
+ const match = ifRegex.exec(content);
3762
+ if (match) {
3763
+ content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
3764
+ }
3765
+ // Replace __TEMPLATE_PATH__ with the actual path
3766
+ content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
3767
+ return content;
3873
3768
  }
3874
3769
  /**
3875
- * Load and parse a config file, returning undefined if missing or invalid.
3770
+ * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
3876
3771
  *
3877
- * @param configDir - Directory containing config.json.
3878
- * @returns Parsed config or undefined.
3772
+ * @param options - Configuration for the refresh cycle.
3879
3773
  */
3880
- function loadConfig(configDir) {
3881
- const configPath = join(configDir, CONFIG_FILE);
3882
- if (!existsSync(configPath))
3883
- return undefined;
3884
- try {
3885
- const raw = readFileSync(configPath, 'utf-8');
3886
- const parsed = JSON.parse(raw);
3887
- return coreConfigSchema.parse(parsed);
3888
- }
3889
- catch {
3890
- return undefined;
3774
+ async function refreshPlatformContent(options) {
3775
+ const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
3776
+ const workspacePath = getWorkspacePath();
3777
+ const coreConfigDir = getCoreConfigDir();
3778
+ // 1. Write calling component's version entry
3779
+ if (componentName) {
3780
+ writeComponentVersion(coreConfigDir, {
3781
+ componentName,
3782
+ pluginVersion: componentVersion,
3783
+ servicePackage,
3784
+ pluginPackage,
3785
+ });
3891
3786
  }
3787
+ // 2. Render Platform template
3788
+ const templatePath = join(coreConfigDir, TEMPLATES_DIR);
3789
+ const platformContent = renderPlatformTemplate(templatePath);
3790
+ // 3. Write TOOLS.md Platform section
3791
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
3792
+ await updateManagedSection(toolsPath, platformContent, {
3793
+ mode: 'section',
3794
+ sectionId: 'Platform',
3795
+ markers: TOOLS_MARKERS,
3796
+ coreVersion,
3797
+ stalenessThresholdMs,
3798
+ });
3799
+ // 4. Write SOUL.md managed block
3800
+ const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
3801
+ await updateManagedSection(soulPath, soulSectionContent, {
3802
+ mode: 'block',
3803
+ markers: SOUL_MARKERS,
3804
+ coreVersion,
3805
+ stalenessThresholdMs,
3806
+ });
3807
+ // 5. Write AGENTS.md managed block
3808
+ const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
3809
+ await updateManagedSection(agentsPath, agentsSectionContent, {
3810
+ mode: 'block',
3811
+ markers: AGENTS_MARKERS,
3812
+ coreVersion,
3813
+ stalenessThresholdMs,
3814
+ });
3815
+ // 6. Copy templates to config dir
3816
+ copyTemplates(coreConfigDir);
3892
3817
  }
3893
3818
 
3894
3819
  /**
3895
- * Service URL resolution.
3820
+ * Cleanup-session escalation for managed files with orphaned duplicated content.
3896
3821
  *
3897
3822
  * @remarks
3898
- * Resolves the URL for a named Jeeves service using the following
3899
- * resolution order:
3900
- * 1. Consumer's own component config
3901
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
3902
- * 3. Default port constants
3823
+ * When a managed file contains the cleanup flag, the writer can ask the
3824
+ * OpenClaw gateway to spawn a background session to remove orphaned content.
3825
+ * The request is best-effort: accepted requests return `true`; any transport
3826
+ * or HTTP failure returns `false` so the file warning remains the fallback.
3903
3827
  */
3828
+ /** Timeout for cleanup-session spawn requests. */
3829
+ const CLEANUP_REQUEST_TIMEOUT_MS = 5_000;
3904
3830
  /**
3905
- * Resolve the URL for a named Jeeves service.
3831
+ * Build the cleanup task prompt sent to the gateway session API.
3906
3832
  *
3907
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
3908
- * @param consumerName - Optional consumer component name for config override.
3909
- * @returns The resolved service URL.
3910
- * @throws Error if `init()` has not been called or the service is unknown.
3833
+ * @param filePath - Managed file requiring cleanup.
3834
+ * @param markerIdentity - Marker identity for the file.
3835
+ * @returns Cleanup instructions for the spawned session.
3911
3836
  */
3912
- function getServiceUrl(serviceName, consumerName) {
3913
- // 1. Check consumer's own config
3914
- if (consumerName) {
3915
- const consumerDir = getComponentConfigDir(consumerName);
3916
- const consumerConfig = loadConfig(consumerDir);
3917
- const consumerUrl = consumerConfig?.services[serviceName]?.url;
3918
- if (consumerUrl)
3919
- return consumerUrl;
3837
+ function buildCleanupTask(filePath, markerIdentity) {
3838
+ return [
3839
+ `Clean up orphaned managed content in ${filePath}.`,
3840
+ `The file uses ${markerIdentity} managed comment markers.`,
3841
+ 'Review content outside the managed block and remove only duplicated managed content.',
3842
+ 'Preserve any unique user-authored content outside the managed block.',
3843
+ 'Do not modify content inside the managed block unless required to preserve valid marker structure.',
3844
+ ].join(' ');
3845
+ }
3846
+ /**
3847
+ * Request a cleanup session from the OpenClaw gateway.
3848
+ *
3849
+ * @remarks
3850
+ * Fire-and-forget. A 200-class response means the request was accepted.
3851
+ * Any HTTP or transport failure returns `false` so the file-level cleanup
3852
+ * warning remains the only signal.
3853
+ *
3854
+ * @param options - Cleanup request configuration.
3855
+ * @returns Whether the gateway accepted the cleanup request.
3856
+ */
3857
+ async function requestCleanupSession(options) {
3858
+ const { gatewayUrl, filePath, markerIdentity } = options;
3859
+ const url = `${gatewayUrl.replace(/\/$/, '')}/sessions/spawn`;
3860
+ const label = `cleanup:${basename(filePath)}`;
3861
+ const body = {
3862
+ task: buildCleanupTask(filePath, markerIdentity),
3863
+ label,
3864
+ };
3865
+ try {
3866
+ const response = await fetchWithTimeout(url, CLEANUP_REQUEST_TIMEOUT_MS, {
3867
+ method: 'POST',
3868
+ headers: { 'Content-Type': 'application/json' },
3869
+ body: JSON.stringify(body),
3870
+ });
3871
+ return response.ok;
3920
3872
  }
3921
- // 2. Check core config
3922
- const coreDir = getCoreConfigDir();
3923
- const coreConfig = loadConfig(coreDir);
3924
- const coreUrl = coreConfig?.services[serviceName]?.url;
3925
- if (coreUrl)
3926
- return coreUrl;
3927
- // 3. Fall back to port constants
3928
- const port = DEFAULT_PORTS[serviceName];
3929
- if (port !== undefined) {
3930
- return `http://127.0.0.1:${String(port)}`;
3873
+ catch {
3874
+ return false;
3931
3875
  }
3932
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
3933
3876
  }
3934
3877
 
3935
3878
  /**
3936
- * Registry version cache for npm package update awareness.
3879
+ * Cleanup flag scanning extracted from ComponentWriter.cycle().
3937
3880
  *
3938
3881
  * @remarks
3939
- * Caches the latest npm registry version in a local JSON file
3940
- * to avoid expensive `npm view` calls on every refresh cycle.
3882
+ * After writing managed files, scans each for the cleanup flag and
3883
+ * fires a best-effort escalation request when a gateway URL is configured.
3884
+ * Uses a `pendingCleanups` set to deduplicate in-flight requests.
3941
3885
  */
3942
3886
  /**
3943
- * Check the npm registry for the latest version of a package.
3887
+ * Scan managed files for the cleanup flag and escalate when detected.
3944
3888
  *
3945
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
3946
- * @param cacheDir - Directory to store the cache file.
3947
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
3948
- * @returns The latest version string, or undefined if the check fails.
3889
+ * @param targets - Managed files to scan.
3890
+ * @param gatewayUrl - Gateway URL for session spawn.
3891
+ * @param pendingCleanups - Set tracking in-flight requests (mutated).
3949
3892
  */
3950
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
3951
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
3952
- // Check cache first
3953
- if (existsSync(cachePath)) {
3893
+ function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
3894
+ for (const target of targets) {
3954
3895
  try {
3955
- const raw = readFileSync(cachePath, 'utf-8');
3956
- const entry = JSON.parse(raw);
3957
- const age = Date.now() - new Date(entry.checkedAt).getTime();
3958
- if (age < ttlSeconds * 1000) {
3959
- return entry.version;
3896
+ if (pendingCleanups.has(target.filePath))
3897
+ continue;
3898
+ const fileContent = readFileSync(target.filePath, 'utf-8');
3899
+ if (fileContent.includes(CLEANUP_FLAG)) {
3900
+ pendingCleanups.add(target.filePath);
3901
+ void requestCleanupSession({
3902
+ gatewayUrl,
3903
+ filePath: target.filePath,
3904
+ markerIdentity: target.markerIdentity,
3905
+ }).finally(() => {
3906
+ pendingCleanups.delete(target.filePath);
3907
+ });
3960
3908
  }
3961
3909
  }
3962
3910
  catch {
3963
- // Cache corrupt — proceed with fresh check
3911
+ // Best-effort: don't fail the cycle for escalation issues.
3964
3912
  }
3965
3913
  }
3966
- // Query npm registry
3967
- try {
3968
- const result = execSync(`npm view ${packageName} version`, {
3969
- encoding: 'utf-8',
3970
- timeout: 15_000,
3971
- stdio: ['pipe', 'pipe', 'pipe'],
3972
- }).trim();
3973
- if (!result)
3974
- return undefined;
3975
- // Write cache
3976
- if (!existsSync(cacheDir)) {
3977
- mkdirSync(cacheDir, { recursive: true });
3914
+ }
3915
+
3916
+ /**
3917
+ * Memory budget accounting and staleness detection for MEMORY.md.
3918
+ *
3919
+ * @remarks
3920
+ * Scans MEMORY.md for ISO date patterns in H2/H3 headings and bullet items.
3921
+ * Reports character count against a configured budget, warning threshold state,
3922
+ * and stale section candidates. Does not auto-delete: review remains
3923
+ * human- or agent-mediated (Decision 42).
3924
+ */
3925
+ /** ISO date pattern: YYYY-MM-DD. */
3926
+ const ISO_DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
3927
+ /** H2 heading pattern used to split sections. */
3928
+ const H2_RE = /^## /m;
3929
+ /**
3930
+ * Extract the most recent ISO date from a string.
3931
+ *
3932
+ * @param text - Text to scan for dates.
3933
+ * @returns The most recent date found, or undefined.
3934
+ */
3935
+ function extractMostRecentDate(text) {
3936
+ const matches = text.match(ISO_DATE_RE);
3937
+ if (!matches)
3938
+ return undefined;
3939
+ let latest;
3940
+ for (const match of matches) {
3941
+ const d = new Date(match + 'T00:00:00Z');
3942
+ if (!Number.isNaN(d.getTime())) {
3943
+ if (!latest || d > latest)
3944
+ latest = d;
3978
3945
  }
3979
- const entry = {
3980
- version: result,
3981
- checkedAt: new Date().toISOString(),
3946
+ }
3947
+ return latest;
3948
+ }
3949
+ /**
3950
+ * Analyze MEMORY.md for budget and staleness.
3951
+ *
3952
+ * @param options - Analysis configuration.
3953
+ * @returns Memory hygiene result.
3954
+ */
3955
+ function analyzeMemory(options) {
3956
+ const { workspacePath, budget, warningThreshold, staleDays } = options;
3957
+ const memoryPath = join(workspacePath, WORKSPACE_FILES.memory);
3958
+ if (!existsSync(memoryPath)) {
3959
+ return {
3960
+ exists: false,
3961
+ charCount: 0,
3962
+ budget,
3963
+ usage: 0,
3964
+ warning: false,
3965
+ overBudget: false,
3966
+ staleCandidates: 0,
3967
+ staleSectionNames: [],
3982
3968
  };
3983
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
3984
- return result;
3985
3969
  }
3986
- catch {
3987
- return undefined;
3970
+ const content = readFileSync(memoryPath, 'utf-8');
3971
+ const charCount = content.length;
3972
+ const usage = budget > 0 ? charCount / budget : charCount > 0 ? Infinity : 0;
3973
+ const warning = usage >= warningThreshold;
3974
+ const overBudget = usage > 1;
3975
+ // Split into H2 sections and scan for staleness
3976
+ const sections = content.split(H2_RE).slice(1); // skip content before first H2
3977
+ const now = Date.now();
3978
+ const thresholdMs = staleDays * 24 * 60 * 60 * 1000;
3979
+ const staleSectionNames = [];
3980
+ for (const section of sections) {
3981
+ const sectionName = section.split('\n')[0]?.trim() ?? '';
3982
+ const recentDate = extractMostRecentDate(section);
3983
+ // Sections without dates are evergreen — never flagged (Decision 47)
3984
+ if (!recentDate)
3985
+ continue;
3986
+ if (now - recentDate.getTime() > thresholdMs) {
3987
+ staleSectionNames.push(sectionName);
3988
+ }
3988
3989
  }
3990
+ return {
3991
+ exists: true,
3992
+ charCount,
3993
+ budget,
3994
+ usage,
3995
+ warning,
3996
+ overBudget,
3997
+ staleCandidates: staleSectionNames.length,
3998
+ staleSectionNames,
3999
+ };
3989
4000
  }
3990
4001
 
3991
4002
  /**
3992
- * HEARTBEAT health orchestration.
4003
+ * HEARTBEAT integration for memory hygiene.
3993
4004
  *
3994
4005
  * @remarks
3995
- * Determines the state of each platform component and generates
3996
- * HEARTBEAT entries with actionable alert text. Applies the dependency
3997
- * graph for alert suppression and auto-decline.
4006
+ * Calls `analyzeMemory()` and converts the result into a `HeartbeatEntry`
4007
+ * suitable for inclusion in the HEARTBEAT.md platform status section.
4008
+ * Returns `undefined` when MEMORY.md is healthy (no alert needed).
4009
+ *
4010
+ * Uses the `## MEMORY.md` heading (Decision 50) to distinguish memory
4011
+ * alerts from component alerts (`## jeeves-{name}`).
3998
4012
  */
3999
- /** Derive the full service name from a component name. */
4000
- function toServiceName(name) {
4001
- return `jeeves-${name}`;
4002
- }
4003
- /** Known dependency declarations for platform components. */
4004
- const COMPONENT_DEPS = {
4005
- meta: { hard: ['watcher'], soft: [] },
4006
- server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
4007
- runner: { hard: [], soft: [] },
4008
- watcher: { hard: [], soft: [] },
4009
- };
4010
- /** "Not installed" alert text for each platform component. Shared with seedContent. */
4011
- const NOT_INSTALLED_ALERTS = {
4012
- 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`.',
4013
- 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`.',
4014
- 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`.',
4015
- 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`.',
4016
- };
4017
- /** Alert text generators by state. */
4018
- const ALERT_TEXT = {
4019
- runner: {
4020
- not_installed: NOT_INSTALLED_ALERTS['runner'],
4021
- 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\`.`,
4022
- 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.',
4023
- 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`.',
4024
- },
4025
- watcher: {
4026
- not_installed: NOT_INSTALLED_ALERTS['watcher'],
4027
- 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`.',
4028
- 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\`.`,
4029
- 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.',
4030
- 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`.',
4031
- },
4032
- server: {
4033
- not_installed: NOT_INSTALLED_ALERTS['server'],
4034
- 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\`.`,
4035
- 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.',
4036
- 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`.',
4037
- },
4038
- meta: {
4039
- not_installed: NOT_INSTALLED_ALERTS['meta'],
4040
- 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.',
4041
- 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\`.`,
4042
- 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.',
4043
- 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`.',
4044
- },
4045
- };
4046
- /** Default Qdrant URL for watcher dependency check. */
4047
- const QDRANT_URL = 'http://127.0.0.1:6333';
4048
- /** Health probe timeout in milliseconds. */
4049
- const PROBE_TIMEOUT_MS$1 = 3000;
4013
+ /** The HEARTBEAT heading name for memory alerts. */
4014
+ const MEMORY_HEARTBEAT_NAME = 'MEMORY.md';
4050
4015
  /**
4051
- * Check if Qdrant is reachable (watcher dependency).
4016
+ * Check memory health and return a HEARTBEAT entry if unhealthy.
4052
4017
  *
4053
- * @returns True if Qdrant responds.
4018
+ * @param options - Memory hygiene options (workspacePath, budget, etc.).
4019
+ * @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
4054
4020
  */
4055
- async function isQdrantAvailable() {
4056
- try {
4057
- await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS$1);
4058
- return true;
4021
+ function checkMemoryHealth(options) {
4022
+ const result = analyzeMemory(options);
4023
+ if (!result.exists)
4024
+ return undefined;
4025
+ if (!result.warning && result.staleCandidates === 0)
4026
+ return undefined;
4027
+ const lines = [];
4028
+ if (result.warning) {
4029
+ const pct = Math.round(result.usage * 100);
4030
+ lines.push(`- Budget: ${result.charCount.toLocaleString()} / ${result.budget.toLocaleString()} chars (${String(pct)}%).${result.overBudget ? ' **Over budget.**' : ' Consider reviewing.'}`);
4059
4031
  }
4060
- catch {
4061
- return false;
4032
+ if (result.staleCandidates > 0) {
4033
+ lines.push(`- ${String(result.staleCandidates)} stale section${result.staleCandidates === 1 ? '' : 's'}: ${result.staleSectionNames.join(', ')}`);
4062
4034
  }
4035
+ return {
4036
+ name: MEMORY_HEARTBEAT_NAME,
4037
+ declined: false,
4038
+ content: lines.join('\n'),
4039
+ };
4063
4040
  }
4041
+
4064
4042
  /**
4065
- * Determine the state of a single component.
4043
+ * HEARTBEAT integration for workspace file size monitoring.
4066
4044
  *
4067
- * @param name - Component name.
4068
- * @param registry - Current component-versions.json contents.
4069
- * @param configRoot - Config root path.
4070
- * @param healthySet - Set of component names known to be healthy (for dep checks).
4071
- * @returns The component's state.
4045
+ * @remarks
4046
+ * Checks all injected workspace files (AGENTS.md, SOUL.md, TOOLS.md,
4047
+ * MEMORY.md, USER.md) against the OpenClaw ~20,000-char injection limit.
4048
+ * Files exceeding the warning threshold generate HEARTBEAT entries with
4049
+ * trimming guidance.
4072
4050
  */
4073
- async function determineComponentState(name, registry, configRoot, healthySet) {
4074
- // Not in registry = not installed
4075
- if (!(name in registry))
4076
- return 'not_installed';
4077
- // Check hard dependencies
4078
- const deps = COMPONENT_DEPS[name];
4079
- for (const hardDep of deps.hard) {
4080
- if (!healthySet.has(hardDep))
4081
- return 'deps_missing';
4082
- }
4083
- // Watcher-specific: check Qdrant
4084
- if (name === 'watcher' && !(await isQdrantAvailable())) {
4085
- return 'deps_missing';
4086
- }
4087
- // Check config file
4088
- const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
4089
- if (!existsSync(configPath))
4090
- return 'config_missing';
4091
- // Fast path: probe HTTP health endpoint
4092
- try {
4093
- const url = getServiceUrl(name);
4094
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
4095
- // Healthy — check for available updates
4096
- const entry = registry[name];
4097
- if (entry.pluginPackage && entry.pluginVersion) {
4098
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
4099
- const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
4100
- if (latestVersion && gt(latestVersion, entry.pluginVersion)) {
4101
- return 'update_available';
4102
- }
4103
- }
4104
- return 'healthy';
4105
- }
4106
- catch {
4107
- // Service not responding — classify sub-state
4108
- const serviceState = getServiceState(toServiceName(name));
4109
- if (serviceState === 'not_installed')
4110
- return 'service_not_installed';
4111
- if (serviceState === 'stopped')
4112
- return 'service_stopped';
4113
- // serviceState === 'running' but HTTP failed — still treat as stopped
4114
- return 'service_stopped';
4115
- }
4116
- }
4051
+ /** Workspace files monitored for size budget. */
4052
+ const WORKSPACE_SIZE_FILES = [
4053
+ 'AGENTS.md',
4054
+ 'SOUL.md',
4055
+ 'TOOLS.md',
4056
+ 'MEMORY.md',
4057
+ 'USER.md',
4058
+ ];
4059
+ /** Trimming guidance lines emitted in HEARTBEAT entries. */
4060
+ const TRIMMING_GUIDANCE = [
4061
+ ' 1. Move domain-specific content to a local skill',
4062
+ ' 2. Extract reference material to companion files with a pointer',
4063
+ ' 3. Summarize verbose instructions',
4064
+ ' 4. Remove stale content',
4065
+ ].join('\n');
4117
4066
  /**
4118
- * Generate the alert text for a component in a given state.
4067
+ * Check all workspace files against the character budget.
4119
4068
  *
4120
- * @param name - Component name.
4121
- * @param state - The component's state.
4122
- * @param configRoot - Config root path.
4123
- * @returns Alert text (list items), or empty string if healthy.
4069
+ * @param options - Health check options.
4070
+ * @returns Array of results, one per checked file (skips non-existent files
4071
+ * unless they breach the budget, which they cannot by definition).
4124
4072
  */
4125
- function generateAlertText(name, state, configRoot, registry) {
4126
- if (state === 'healthy')
4127
- return '';
4128
- // Update available — dynamic text with version info
4129
- if (state === 'update_available') {
4130
- const entry = registry[name];
4131
- const currentVersion = entry.pluginVersion ?? 'unknown';
4132
- const componentConfigDir = join(configRoot, `jeeves-${name}`);
4133
- const latestVersion = entry.pluginPackage
4134
- ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
4135
- 'unknown')
4136
- : 'unknown';
4137
- const installCmd = entry.pluginPackage
4138
- ? `\`npx ${entry.pluginPackage} install\``
4139
- : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
4140
- return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
4141
- }
4142
- const componentAlerts = ALERT_TEXT[name];
4143
- const alertOrFn = componentAlerts[state];
4144
- if (!alertOrFn)
4145
- return '';
4146
- const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
4147
- return `- ${text}`;
4073
+ function checkWorkspaceFileHealth(options) {
4074
+ const { workspacePath, budgetChars = 20_000, warningThreshold = 0.8, } = options;
4075
+ return WORKSPACE_SIZE_FILES.map((file) => {
4076
+ const filePath = join(workspacePath, file);
4077
+ if (!existsSync(filePath)) {
4078
+ return {
4079
+ file,
4080
+ exists: false,
4081
+ charCount: 0,
4082
+ budget: budgetChars,
4083
+ usage: 0,
4084
+ warning: false,
4085
+ overBudget: false,
4086
+ };
4087
+ }
4088
+ const content = readFileSync(filePath, 'utf-8');
4089
+ const charCount = content.length;
4090
+ const usage = charCount / budgetChars;
4091
+ return {
4092
+ file,
4093
+ exists: true,
4094
+ charCount,
4095
+ budget: budgetChars,
4096
+ usage,
4097
+ warning: usage >= warningThreshold,
4098
+ overBudget: charCount > budgetChars,
4099
+ };
4100
+ });
4148
4101
  }
4149
4102
  /**
4150
- * Orchestrate HEARTBEAT entries for all platform components.
4103
+ * Convert workspace file health results into HEARTBEAT entries.
4151
4104
  *
4152
- * @param options - Orchestration configuration.
4153
- * @returns Array of HeartbeatEntry for writeHeartbeatSection.
4105
+ * @param results - Results from `checkWorkspaceFileHealth`.
4106
+ * @returns Array of `HeartbeatEntry` objects for files that exceed the
4107
+ * warning threshold.
4154
4108
  */
4155
- async function orchestrateHeartbeat(options) {
4156
- const { coreConfigDir, configRoot, declinedNames } = options;
4157
- const registry = readComponentVersions(coreConfigDir);
4158
- // First pass: determine which components are healthy (for dep resolution)
4159
- const healthySet = new Set();
4160
- for (const name of PLATFORM_COMPONENTS) {
4161
- if (declinedNames.has(toServiceName(name)))
4162
- continue;
4163
- if (!(name in registry))
4164
- continue;
4165
- try {
4166
- const url = getServiceUrl(name);
4167
- await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
4168
- healthySet.add(name);
4169
- }
4170
- catch {
4171
- // Not healthy — will be classified in second pass
4172
- }
4173
- }
4174
- // Second pass: generate entries
4175
- const entries = [];
4176
- for (const name of PLATFORM_COMPONENTS) {
4177
- const fullName = toServiceName(name);
4178
- // Declined
4179
- if (declinedNames.has(fullName)) {
4180
- // Auto-decline dependents of declined hard deps
4181
- entries.push({ name: fullName, declined: true, content: '' });
4182
- continue;
4183
- }
4184
- const state = await determineComponentState(name, registry, configRoot, healthySet);
4185
- // Auto-decline if hard dep is declined
4186
- const deps = COMPONENT_DEPS[name];
4187
- const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
4188
- if (hardDepDeclined) {
4189
- entries.push({ name: fullName, declined: true, content: '' });
4190
- continue;
4191
- }
4192
- const alertText = generateAlertText(name, state, configRoot, registry);
4193
- entries.push({ name: fullName, declined: false, content: alertText });
4194
- }
4195
- // Add soft-dep informational alerts for any healthy component with soft deps
4196
- for (const entry of entries) {
4197
- if (entry.declined || entry.content)
4198
- continue;
4199
- // Entry is healthy (no alert, not declined) — check for soft deps
4200
- const shortName = entry.name.replace(/^jeeves-/, '');
4201
- const deps = COMPONENT_DEPS[shortName];
4202
- if (!deps.soft.length)
4203
- continue;
4204
- const softAlerts = [];
4205
- for (const dep of deps.soft) {
4206
- const depFullName = toServiceName(dep);
4207
- if (declinedNames.has(depFullName))
4208
- continue;
4209
- if (!healthySet.has(dep)) {
4210
- softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
4211
- }
4212
- }
4213
- if (softAlerts.length > 0) {
4214
- entry.content = softAlerts.join('\n');
4215
- }
4216
- }
4217
- return entries;
4109
+ function workspaceFileHealthEntries(results) {
4110
+ return results
4111
+ .filter((r) => r.exists && r.warning)
4112
+ .map((r) => {
4113
+ const pct = Math.round(r.usage * 100);
4114
+ const overBudgetNote = r.overBudget ? ' **Over budget.**' : '';
4115
+ const content = [
4116
+ `- Budget: ${r.charCount.toLocaleString()} / ${r.budget.toLocaleString()} chars (${String(pct)}%).${overBudgetNote} Trim to stay under the OpenClaw injection limit.`,
4117
+ `- Suggested trimming priority:\n${TRIMMING_GUIDANCE}`,
4118
+ ].join('\n');
4119
+ return {
4120
+ name: r.file,
4121
+ declined: false,
4122
+ content,
4123
+ };
4124
+ });
4218
4125
  }
4219
4126
 
4220
4127
  /**
4221
- * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
4128
+ * Core configuration schema and resolution.
4222
4129
  *
4223
4130
  * @remarks
4224
- * Reads existing HEARTBEAT.md, resolves declined components, runs the
4225
- * heartbeat state machine, and writes the result. Best-effort: failures
4226
- * are logged but do not propagate.
4131
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
4132
+ * Config resolution order:
4133
+ * 1. Component's own config file
4134
+ * 2. Core config file
4135
+ * 3. Hardcoded library defaults
4227
4136
  */
4137
+ /** Zod schema for a service entry in core config. */
4138
+ const serviceEntrySchema = z.object({
4139
+ /** Service URL (must be a valid URL). */
4140
+ url: z.url().describe('Service URL'),
4141
+ });
4142
+ /** Default bind address for all Jeeves services. */
4143
+ const DEFAULT_BIND_ADDRESS = '0.0.0.0';
4144
+ /** Zod schema for the core config file. */
4145
+ const coreConfigSchema = z.object({
4146
+ /** JSON Schema pointer for IDE autocomplete. */
4147
+ $schema: z.string().optional().describe('JSON Schema pointer'),
4148
+ /** Owner identity keys (canonical identityLinks references). */
4149
+ owners: z.array(z.string()).default([]).describe('Owner identity keys'),
4150
+ /**
4151
+ * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
4152
+ * Individual components can override in their own config.
4153
+ */
4154
+ bindAddress: z
4155
+ .string()
4156
+ .default(DEFAULT_BIND_ADDRESS)
4157
+ .describe('Bind address for all Jeeves services'),
4158
+ /** Service URL overrides keyed by service name. */
4159
+ services: z
4160
+ .record(z.string(), serviceEntrySchema)
4161
+ .default({})
4162
+ .describe('Service URL overrides'),
4163
+ /** Registry cache configuration. */
4164
+ registryCache: z
4165
+ .object({
4166
+ /** Cache TTL in seconds for npm registry queries. */
4167
+ ttlSeconds: z
4168
+ .number()
4169
+ .int()
4170
+ .positive()
4171
+ .default(3600)
4172
+ .describe('Cache TTL in seconds'),
4173
+ })
4174
+ .prefault({})
4175
+ .describe('Registry cache settings'),
4176
+ });
4228
4177
  /**
4229
- * Read a file's content, returning empty string if the file does not exist.
4178
+ * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
4230
4179
  *
4231
- * @param filePath - Absolute file path.
4232
- * @returns File content or empty string.
4180
+ * @returns A JSON Schema object.
4233
4181
  */
4234
- function readFileOrEmpty(filePath) {
4235
- try {
4236
- return readFileSync(filePath, 'utf-8');
4237
- }
4238
- catch (err) {
4239
- if (err instanceof Error &&
4240
- 'code' in err &&
4241
- err.code === 'ENOENT') {
4242
- return '';
4243
- }
4244
- throw err;
4245
- }
4182
+ function generateJsonSchema() {
4183
+ return {
4184
+ $schema: 'http://json-schema.org/draft-07/schema#',
4185
+ title: 'Jeeves Core Configuration',
4186
+ type: 'object',
4187
+ properties: {
4188
+ $schema: { type: 'string' },
4189
+ owners: {
4190
+ type: 'array',
4191
+ items: { type: 'string' },
4192
+ default: [],
4193
+ },
4194
+ bindAddress: {
4195
+ type: 'string',
4196
+ default: '0.0.0.0',
4197
+ description: 'Bind address for all Jeeves services',
4198
+ },
4199
+ services: {
4200
+ type: 'object',
4201
+ additionalProperties: {
4202
+ type: 'object',
4203
+ properties: {
4204
+ url: { type: 'string', format: 'uri' },
4205
+ },
4206
+ required: ['url'],
4207
+ },
4208
+ default: {},
4209
+ },
4210
+ registryCache: {
4211
+ type: 'object',
4212
+ properties: {
4213
+ ttlSeconds: {
4214
+ type: 'integer',
4215
+ minimum: 1,
4216
+ default: 3600,
4217
+ },
4218
+ },
4219
+ default: {},
4220
+ },
4221
+ },
4222
+ };
4246
4223
  }
4247
4224
  /**
4248
- * Run a single HEARTBEAT orchestration cycle.
4225
+ * Load and parse a config file, returning undefined if missing or invalid.
4249
4226
  *
4250
- * @param options - Heartbeat cycle configuration.
4227
+ * @param configDir - Directory containing config.json.
4228
+ * @returns Parsed config or undefined.
4251
4229
  */
4252
- async function runHeartbeatCycle(options) {
4253
- const { workspacePath, coreConfigDir, configRoot } = options;
4254
- const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
4230
+ function loadConfig(configDir) {
4231
+ const configPath = join(configDir, CONFIG_FILE);
4232
+ if (!existsSync(configPath))
4233
+ return undefined;
4255
4234
  try {
4256
- const existingContent = readFileOrEmpty(heartbeatPath);
4257
- const parsed = parseHeartbeat(existingContent);
4258
- const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
4259
- const entries = await orchestrateHeartbeat({
4260
- coreConfigDir,
4261
- configRoot,
4262
- declinedNames,
4263
- });
4264
- // Memory hygiene check (Decision 49)
4265
- if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
4266
- const wsConfig = loadWorkspaceConfig(workspacePath);
4267
- const memoryEntry = checkMemoryHealth({
4268
- workspacePath,
4269
- budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
4270
- warningThreshold: wsConfig?.memory?.warningThreshold ??
4271
- WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
4272
- staleDays: wsConfig?.memory?.staleDays ??
4273
- WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
4274
- });
4275
- if (memoryEntry)
4276
- entries.push(memoryEntry);
4277
- }
4278
- else {
4279
- entries.push({
4280
- name: MEMORY_HEARTBEAT_NAME,
4281
- declined: true,
4282
- content: '',
4283
- });
4284
- }
4285
- // Workspace file size health check (Decision 70)
4286
- const wsFileResults = checkWorkspaceFileHealth({ workspacePath });
4287
- const wsFileAlerts = workspaceFileHealthEntries(wsFileResults);
4288
- for (const alert of wsFileAlerts) {
4289
- if (declinedNames.has(alert.name)) {
4290
- entries.push({ name: alert.name, declined: true, content: '' });
4291
- }
4292
- else {
4293
- entries.push(alert);
4294
- }
4295
- }
4296
- await writeHeartbeatSection(heartbeatPath, entries);
4235
+ const raw = readFileSync(configPath, 'utf-8');
4236
+ const parsed = JSON.parse(raw);
4237
+ return coreConfigSchema.parse(parsed);
4297
4238
  }
4298
- catch (err) {
4299
- console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${getErrorMessage(err)}`);
4239
+ catch {
4240
+ return undefined;
4300
4241
  }
4301
4242
  }
4302
4243
 
4303
4244
  /**
4304
- * Timer-based orchestrator for managed content writing.
4245
+ * Service URL resolution.
4305
4246
  *
4306
4247
  * @remarks
4307
- * `ComponentWriter` manages a component's TOOLS.md section writes
4308
- * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
4309
- * on a configurable prime-interval timer cycle.
4248
+ * Resolves the URL for a named Jeeves service using the following
4249
+ * resolution order:
4250
+ * 1. Consumer's own component config
4251
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
4252
+ * 3. Default port constants
4310
4253
  */
4311
4254
  /**
4312
- * Orchestrates managed content writing for a single Jeeves component.
4313
- *
4314
- * @remarks
4315
- * Created via {@link createComponentWriter}. Manages a timer that fires
4316
- * at the component's prime-interval, calling `generateToolsContent()`
4317
- * and `refreshPlatformContent()` on each cycle.
4318
- */
4319
- class ComponentWriter {
4320
- timer;
4321
- jitterTimeout;
4322
- component;
4323
- configDir;
4324
- gatewayUrl;
4325
- pendingCleanups = new Set();
4326
- /** @internal */
4327
- constructor(component, options) {
4328
- this.component = component;
4329
- this.configDir = getComponentConfigDir(component.name);
4330
- this.gatewayUrl = options?.gatewayUrl;
4331
- }
4332
- /** The component's config directory path. */
4333
- get componentConfigDir() {
4334
- return this.configDir;
4335
- }
4336
- /** Whether the writer timer is currently running or pending its first cycle. */
4337
- get isRunning() {
4338
- return this.jitterTimeout !== undefined || this.timer !== undefined;
4339
- }
4340
- /**
4341
- * Start the writer timer.
4342
- *
4343
- * @remarks
4344
- * Delays the first cycle by a random jitter (0 to one full interval) to
4345
- * spread initial writes across all component plugins and reduce EPERM
4346
- * contention on startup.
4347
- */
4348
- start() {
4349
- if (this.isRunning)
4350
- return;
4351
- // Random jitter up to one full interval to spread initial writes
4352
- const intervalMs = this.component.refreshIntervalSeconds * 1000;
4353
- const jitterMs = Math.floor(Math.random() * intervalMs);
4354
- this.jitterTimeout = setTimeout(() => {
4355
- this.jitterTimeout = undefined;
4356
- void this.cycle();
4357
- this.timer = setInterval(() => void this.cycle(), intervalMs);
4358
- }, jitterMs);
4359
- }
4360
- /** Stop the writer timer. */
4361
- stop() {
4362
- if (this.jitterTimeout) {
4363
- clearTimeout(this.jitterTimeout);
4364
- this.jitterTimeout = undefined;
4365
- }
4366
- if (this.timer) {
4367
- clearInterval(this.timer);
4368
- this.timer = undefined;
4369
- }
4370
- }
4371
- /**
4372
- * Execute a single write cycle.
4373
- *
4374
- * @remarks
4375
- * 1. Write the component's TOOLS.md section.
4376
- * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
4377
- * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
4378
- * 4. Run HEARTBEAT health orchestration.
4379
- */
4380
- async cycle() {
4381
- try {
4382
- const workspacePath = getWorkspacePath();
4383
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
4384
- // 1. Write the component's TOOLS.md section
4385
- const toolsContent = this.component.generateToolsContent();
4386
- await updateManagedSection(toolsPath, toolsContent, {
4387
- mode: 'section',
4388
- sectionId: this.component.sectionId,
4389
- markers: TOOLS_MARKERS,
4390
- coreVersion: CORE_VERSION,
4391
- });
4392
- // 2. Platform content maintenance
4393
- await refreshPlatformContent({
4394
- coreVersion: CORE_VERSION,
4395
- componentName: this.component.name,
4396
- componentVersion: this.component.version,
4397
- servicePackage: this.component.servicePackage,
4398
- pluginPackage: this.component.pluginPackage,
4399
- });
4400
- // 3. Cleanup escalation
4401
- if (this.gatewayUrl) {
4402
- scanAndEscalateCleanup([
4403
- { filePath: toolsPath, markerIdentity: 'TOOLS' },
4404
- {
4405
- filePath: join(workspacePath, WORKSPACE_FILES.soul),
4406
- markerIdentity: 'SOUL',
4407
- },
4408
- {
4409
- filePath: join(workspacePath, WORKSPACE_FILES.agents),
4410
- markerIdentity: 'AGENTS',
4411
- },
4412
- ], this.gatewayUrl, this.pendingCleanups);
4413
- }
4414
- // 4. HEARTBEAT orchestration
4415
- await runHeartbeatCycle({
4416
- workspacePath,
4417
- coreConfigDir: getCoreConfigDir(),
4418
- configRoot: getConfigRoot(),
4419
- });
4420
- }
4421
- catch (err) {
4422
- console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${getErrorMessage(err)}`);
4423
- }
4255
+ * Resolve the URL for a named Jeeves service.
4256
+ *
4257
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
4258
+ * @param consumerName - Optional consumer component name for config override.
4259
+ * @returns The resolved service URL.
4260
+ * @throws Error if `init()` has not been called or the service is unknown.
4261
+ */
4262
+ function getServiceUrl(serviceName, consumerName) {
4263
+ // 1. Check consumer's own config
4264
+ if (consumerName) {
4265
+ const consumerDir = getComponentConfigDir(consumerName);
4266
+ const consumerConfig = loadConfig(consumerDir);
4267
+ const consumerUrl = consumerConfig?.services[serviceName]?.url;
4268
+ if (consumerUrl)
4269
+ return consumerUrl;
4270
+ }
4271
+ // 2. Check core config
4272
+ const coreDir = getCoreConfigDir();
4273
+ const coreConfig = loadConfig(coreDir);
4274
+ const coreUrl = coreConfig?.services[serviceName]?.url;
4275
+ if (coreUrl)
4276
+ return coreUrl;
4277
+ // 3. Fall back to port constants
4278
+ const port = DEFAULT_PORTS[serviceName];
4279
+ if (port !== undefined) {
4280
+ return `http://127.0.0.1:${String(port)}`;
4424
4281
  }
4282
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
4425
4283
  }
4426
4284
 
4427
4285
  /**
4428
- * Creates a synchronous content accessor backed by an async data source.
4286
+ * Registry version cache for npm package update awareness.
4429
4287
  *
4430
4288
  * @remarks
4431
- * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
4432
- * the interface is synchronous, but most components fetch live data from
4433
- * their HTTP service. This utility returns a sync `() => string` that
4434
- * serves the last successfully fetched value while kicking off a background
4435
- * refresh on each call.
4436
- *
4437
- * First call returns `placeholder`. Subsequent calls return the last
4438
- * successfully fetched content. If a refresh fails, the previous good
4439
- * value is retained.
4440
- *
4441
- * @example
4442
- * ```typescript
4443
- * const getContent = createAsyncContentCache({
4444
- * fetch: async () => {
4445
- * const res = await fetch('http://127.0.0.1:1936/status');
4446
- * return formatWatcherStatus(await res.json());
4447
- * },
4448
- * placeholder: '> Initializing watcher status...',
4449
- * });
4450
- *
4451
- * const writer = createComponentWriter({
4452
- * // ...
4453
- * generateToolsContent: getContent,
4454
- * });
4455
- * ```
4289
+ * Caches the latest npm registry version in a local JSON file
4290
+ * to avoid expensive `npm view` calls on every refresh cycle.
4456
4291
  */
4457
4292
  /**
4458
- * Creates a synchronous content accessor backed by an async data source.
4293
+ * Check the npm registry for the latest version of a package.
4459
4294
  *
4460
- * @param options - Cache configuration.
4461
- * @returns A sync `() => string` suitable for `generateToolsContent`.
4295
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
4296
+ * @param cacheDir - Directory to store the cache file.
4297
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
4298
+ * @returns The latest version string, or undefined if the check fails.
4462
4299
  */
4463
- function createAsyncContentCache(options) {
4464
- const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
4465
- console.warn('[jeeves] async content cache refresh failed:', err);
4466
- }, } = options;
4467
- let cached = placeholder;
4468
- let refreshing = false;
4469
- return () => {
4470
- if (!refreshing) {
4471
- refreshing = true;
4472
- fetchContent()
4473
- .then((content) => {
4474
- cached = content;
4475
- })
4476
- .catch(onError)
4477
- .finally(() => {
4478
- refreshing = false;
4479
- });
4300
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
4301
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
4302
+ // Check cache first
4303
+ if (existsSync(cachePath)) {
4304
+ try {
4305
+ const raw = readFileSync(cachePath, 'utf-8');
4306
+ const entry = JSON.parse(raw);
4307
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
4308
+ if (age < ttlSeconds * 1000) {
4309
+ return entry.version;
4310
+ }
4480
4311
  }
4481
- return cached;
4482
- };
4312
+ catch {
4313
+ // Cache corrupt — proceed with fresh check
4314
+ }
4315
+ }
4316
+ // Query npm registry
4317
+ try {
4318
+ const result = execSync(`npm view ${packageName} version`, {
4319
+ encoding: 'utf-8',
4320
+ timeout: 15_000,
4321
+ stdio: ['pipe', 'pipe', 'pipe'],
4322
+ }).trim();
4323
+ if (!result)
4324
+ return undefined;
4325
+ // Write cache
4326
+ if (!existsSync(cacheDir)) {
4327
+ mkdirSync(cacheDir, { recursive: true });
4328
+ }
4329
+ const entry = {
4330
+ version: result,
4331
+ checkedAt: new Date().toISOString(),
4332
+ };
4333
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
4334
+ return result;
4335
+ }
4336
+ catch {
4337
+ return undefined;
4338
+ }
4483
4339
  }
4484
4340
 
4485
4341
  /**
4486
- * Factory function for creating a ComponentWriter from a descriptor.
4487
- *
4488
- * @remarks
4489
- * Validates the descriptor via Zod schema and creates a ComponentWriter.
4490
- * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
4491
- * `JeevesComponent` interface is no longer accepted.
4492
- */
4493
- /**
4494
- * Create a ComponentWriter for a validated component descriptor.
4342
+ * HEARTBEAT health orchestration.
4495
4343
  *
4496
4344
  * @remarks
4497
- * The descriptor is validated via the Zod schema at runtime.
4498
- * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
4499
- *
4500
- * @param descriptor - The component descriptor to validate and wrap.
4501
- * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
4502
- * @returns A new `ComponentWriter` instance.
4503
- * @throws ZodError if the descriptor is invalid.
4345
+ * Determines the state of each platform component and generates
4346
+ * HEARTBEAT entries with actionable alert text. Applies the dependency
4347
+ * graph for alert suppression and auto-decline.
4504
4348
  */
4505
- function createComponentWriter(descriptor, options) {
4506
- // Validate via Zod — throws ZodError with detailed messages on failure
4507
- jeevesComponentDescriptorSchema.parse(descriptor);
4508
- return new ComponentWriter(descriptor, options);
4349
+ /** Derive the full service name from a component name. */
4350
+ function toServiceName(name) {
4351
+ return `jeeves-${name}`;
4509
4352
  }
4510
-
4353
+ /** Known dependency declarations for platform components. */
4354
+ const COMPONENT_DEPS = {
4355
+ meta: { hard: ['watcher'], soft: [] },
4356
+ server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
4357
+ runner: { hard: [], soft: [] },
4358
+ watcher: { hard: [], soft: [] },
4359
+ };
4360
+ /** "Not installed" alert text for each platform component. Shared with seedContent. */
4361
+ const NOT_INSTALLED_ALERTS = {
4362
+ 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`.',
4363
+ 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`.',
4364
+ 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`.',
4365
+ 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`.',
4366
+ };
4367
+ /** Alert text generators by state. */
4368
+ const ALERT_TEXT = {
4369
+ runner: {
4370
+ not_installed: NOT_INSTALLED_ALERTS['runner'],
4371
+ 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\`.`,
4372
+ 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.',
4373
+ 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`.',
4374
+ },
4375
+ watcher: {
4376
+ not_installed: NOT_INSTALLED_ALERTS['watcher'],
4377
+ 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`.',
4378
+ 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\`.`,
4379
+ 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.',
4380
+ 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`.',
4381
+ },
4382
+ server: {
4383
+ not_installed: NOT_INSTALLED_ALERTS['server'],
4384
+ 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\`.`,
4385
+ 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.',
4386
+ 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`.',
4387
+ },
4388
+ meta: {
4389
+ not_installed: NOT_INSTALLED_ALERTS['meta'],
4390
+ 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.',
4391
+ 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\`.`,
4392
+ 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.',
4393
+ 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`.',
4394
+ },
4395
+ };
4396
+ /** Default Qdrant URL for watcher dependency check. */
4397
+ const QDRANT_URL = 'http://127.0.0.1:6333';
4398
+ /** Health probe timeout in milliseconds. */
4399
+ const PROBE_TIMEOUT_MS = 3000;
4511
4400
  /**
4512
- * Resolve the bind address for a Jeeves service.
4401
+ * Check if Qdrant is reachable (watcher dependency).
4513
4402
  *
4514
- * @remarks
4515
- * Resolution order (four-tier):
4516
- * 1. Component config `bindAddress` field (if componentName provided)
4517
- * 2. Core config `bindAddress` field
4518
- * 3. `JEEVES_BIND_ADDRESS` environment variable
4519
- * 4. Default: `0.0.0.0`
4403
+ * @returns True if Qdrant responds.
4520
4404
  */
4405
+ async function isQdrantAvailable() {
4406
+ try {
4407
+ await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS);
4408
+ return true;
4409
+ }
4410
+ catch {
4411
+ return false;
4412
+ }
4413
+ }
4521
4414
  /**
4522
- * Resolve the bind address for a Jeeves service.
4415
+ * Determine the state of a single component.
4523
4416
  *
4524
- * @param componentName - Optional component name for component-specific override.
4525
- * @returns The resolved bind address.
4417
+ * @param name - Component name.
4418
+ * @param registry - Current component-versions.json contents.
4419
+ * @param configRoot - Config root path.
4420
+ * @param healthySet - Set of component names known to be healthy (for dep checks).
4421
+ * @returns The component's state.
4526
4422
  */
4527
- function getBindAddress(componentName) {
4528
- // Tier 1: Component config (if provided)
4529
- if (componentName) {
4530
- const componentConfig = loadConfig(getComponentConfigDir(componentName));
4531
- if (componentConfig?.bindAddress) {
4532
- return componentConfig.bindAddress;
4533
- }
4423
+ async function determineComponentState(name, registry, configRoot, healthySet) {
4424
+ // Not in registry = not installed
4425
+ if (!(name in registry))
4426
+ return 'not_installed';
4427
+ // Check hard dependencies
4428
+ const deps = COMPONENT_DEPS[name];
4429
+ for (const hardDep of deps.hard) {
4430
+ if (!healthySet.has(hardDep))
4431
+ return 'deps_missing';
4534
4432
  }
4535
- // Tier 2: Core config
4536
- const coreConfig = loadConfig(getCoreConfigDir());
4537
- if (coreConfig?.bindAddress) {
4538
- return coreConfig.bindAddress;
4433
+ // Watcher-specific: check Qdrant
4434
+ if (name === 'watcher' && !(await isQdrantAvailable())) {
4435
+ return 'deps_missing';
4436
+ }
4437
+ // Check config file
4438
+ const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
4439
+ if (!existsSync(configPath))
4440
+ return 'config_missing';
4441
+ // Fast path: probe HTTP health endpoint
4442
+ try {
4443
+ const url = getServiceUrl(name);
4444
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
4445
+ // Healthy — check for available updates
4446
+ const entry = registry[name];
4447
+ if (entry.pluginPackage && entry.pluginVersion) {
4448
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
4449
+ const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
4450
+ if (latestVersion && gt(latestVersion, entry.pluginVersion)) {
4451
+ return 'update_available';
4452
+ }
4453
+ }
4454
+ return 'healthy';
4539
4455
  }
4540
- // Tier 3: Environment variable
4541
- const envValue = process.env['JEEVES_BIND_ADDRESS'];
4542
- if (envValue) {
4543
- return envValue;
4456
+ catch {
4457
+ // Service not responding — classify sub-state
4458
+ const serviceState = getServiceState(toServiceName(name));
4459
+ if (serviceState === 'not_installed')
4460
+ return 'service_not_installed';
4461
+ if (serviceState === 'stopped')
4462
+ return 'service_stopped';
4463
+ // serviceState === 'running' but HTTP failed — still treat as stopped
4464
+ return 'service_stopped';
4544
4465
  }
4545
- // Tier 4: Default
4546
- return DEFAULT_BIND_ADDRESS;
4547
4466
  }
4548
-
4549
- /**
4550
- * One-shot content seeding used by the CLI install command.
4551
- *
4552
- * @remarks
4553
- * Seeds SOUL.md, AGENTS.md, and TOOLS.md Platform section using the same
4554
- * `updateManagedSection()` code path as writer cycles. Also copies templates
4555
- * and creates core config with defaults if missing.
4556
- */
4557
4467
  /**
4558
- * Create the core config file with defaults if it doesn't already exist.
4468
+ * Generate the alert text for a component in a given state.
4559
4469
  *
4560
- * @param coreConfigDir - Path to the core config directory.
4470
+ * @param name - Component name.
4471
+ * @param state - The component's state.
4472
+ * @param configRoot - Config root path.
4473
+ * @returns Alert text (list items), or empty string if healthy.
4561
4474
  */
4562
- function ensureCoreConfig(coreConfigDir) {
4563
- if (!existsSync(coreConfigDir)) {
4564
- mkdirSync(coreConfigDir, { recursive: true });
4475
+ function generateAlertText(name, state, configRoot, registry) {
4476
+ if (state === 'healthy')
4477
+ return '';
4478
+ // Update available — dynamic text with version info
4479
+ if (state === 'update_available') {
4480
+ const entry = registry[name];
4481
+ const currentVersion = entry.pluginVersion ?? 'unknown';
4482
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
4483
+ const latestVersion = entry.pluginPackage
4484
+ ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
4485
+ 'unknown')
4486
+ : 'unknown';
4487
+ const installCmd = entry.pluginPackage
4488
+ ? `\`npx ${entry.pluginPackage} install\``
4489
+ : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
4490
+ return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
4565
4491
  }
4566
- const configPath = join(coreConfigDir, CONFIG_FILE);
4567
- if (existsSync(configPath))
4568
- return;
4569
- const defaults = coreConfigSchema.parse({});
4570
- const configWithSchema = {
4571
- $schema: './config.schema.json',
4572
- ...defaults,
4573
- };
4574
- writeFileSync(configPath, JSON.stringify(configWithSchema, null, 2), 'utf-8');
4575
- // Write JSON schema file alongside config
4576
- const schemaPath = join(coreConfigDir, 'config.schema.json');
4577
- const jsonSchema = generateJsonSchema();
4578
- writeFileSync(schemaPath, JSON.stringify(jsonSchema, null, 2), 'utf-8');
4492
+ const componentAlerts = ALERT_TEXT[name];
4493
+ const alertOrFn = componentAlerts[state];
4494
+ if (!alertOrFn)
4495
+ return '';
4496
+ const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
4497
+ return `- ${text}`;
4579
4498
  }
4580
4499
  /**
4581
- * Seed all platform content into the workspace.
4582
- *
4583
- * @remarks
4584
- * Uses the same `updateManagedSection()` code path as writer cycles.
4585
- * Creates core config with defaults if missing. Copies templates.
4586
- * Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
4587
- * Jaccard cleanup detection runs automatically via `updateManagedSection`.
4500
+ * Orchestrate HEARTBEAT entries for all platform components.
4588
4501
  *
4589
- * @param options - Seeding configuration.
4502
+ * @param options - Orchestration configuration.
4503
+ * @returns Array of HeartbeatEntry for writeHeartbeatSection.
4590
4504
  */
4591
- async function seedContent(options) {
4592
- const coreConfigDir = getCoreConfigDir();
4593
- // Ensure core config exists
4594
- ensureCoreConfig(coreConfigDir);
4595
- // Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
4596
- await refreshPlatformContent({
4597
- coreVersion: options.coreVersion,
4598
- });
4599
- // Seed HEARTBEAT.md with "Not installed" alerts for all platform components
4600
- const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
4601
- const entries = PLATFORM_COMPONENTS.map((name) => ({
4602
- name: toServiceName(name),
4603
- declined: false,
4604
- content: `- ${NOT_INSTALLED_ALERTS[name]}`,
4605
- }));
4606
- await writeHeartbeatSection(heartbeatPath, entries);
4607
- // Seed jeeves workspace skill (Decision 48: overwrite-on-install)
4608
- seedSkill(getWorkspacePath());
4505
+ async function orchestrateHeartbeat(options) {
4506
+ const { coreConfigDir, configRoot, declinedNames } = options;
4507
+ const registry = readComponentVersions(coreConfigDir);
4508
+ // First pass: determine which components are healthy (for dep resolution)
4509
+ const healthySet = new Set();
4510
+ for (const name of PLATFORM_COMPONENTS) {
4511
+ if (declinedNames.has(toServiceName(name)))
4512
+ continue;
4513
+ if (!(name in registry))
4514
+ continue;
4515
+ try {
4516
+ const url = getServiceUrl(name);
4517
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
4518
+ healthySet.add(name);
4519
+ }
4520
+ catch {
4521
+ // Not healthy — will be classified in second pass
4522
+ }
4523
+ }
4524
+ // Second pass: generate entries
4525
+ const entries = [];
4526
+ for (const name of PLATFORM_COMPONENTS) {
4527
+ const fullName = toServiceName(name);
4528
+ // Declined
4529
+ if (declinedNames.has(fullName)) {
4530
+ // Auto-decline dependents of declined hard deps
4531
+ entries.push({ name: fullName, declined: true, content: '' });
4532
+ continue;
4533
+ }
4534
+ const state = await determineComponentState(name, registry, configRoot, healthySet);
4535
+ // Auto-decline if hard dep is declined
4536
+ const deps = COMPONENT_DEPS[name];
4537
+ const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
4538
+ if (hardDepDeclined) {
4539
+ entries.push({ name: fullName, declined: true, content: '' });
4540
+ continue;
4541
+ }
4542
+ const alertText = generateAlertText(name, state, configRoot, registry);
4543
+ entries.push({ name: fullName, declined: false, content: alertText });
4544
+ }
4545
+ // Add soft-dep informational alerts for any healthy component with soft deps
4546
+ for (const entry of entries) {
4547
+ if (entry.declined || entry.content)
4548
+ continue;
4549
+ // Entry is healthy (no alert, not declined) — check for soft deps
4550
+ const shortName = entry.name.replace(/^jeeves-/, '');
4551
+ const deps = COMPONENT_DEPS[shortName];
4552
+ if (!deps.soft.length)
4553
+ continue;
4554
+ const softAlerts = [];
4555
+ for (const dep of deps.soft) {
4556
+ const depFullName = toServiceName(dep);
4557
+ if (declinedNames.has(depFullName))
4558
+ continue;
4559
+ if (!healthySet.has(dep)) {
4560
+ softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
4561
+ }
4562
+ }
4563
+ if (softAlerts.length > 0) {
4564
+ entry.content = softAlerts.join('\n');
4565
+ }
4566
+ }
4567
+ return entries;
4609
4568
  }
4610
4569
 
4611
4570
  /**
4612
- * Tool result formatters for the OpenClaw plugin SDK.
4571
+ * HEARTBEAT orchestration extracted from ComponentWriter.cycle().
4613
4572
  *
4614
4573
  * @remarks
4615
- * Provides standardised helpers for building `ToolResult` objects:
4616
- * success, error, and connection-error variants.
4617
- */
4618
- /**
4619
- * Format a successful tool result.
4620
- *
4621
- * @param data - Arbitrary data to return as JSON.
4622
- * @returns A `ToolResult` with JSON-stringified content.
4574
+ * Reads existing HEARTBEAT.md, resolves declined components, runs the
4575
+ * heartbeat state machine, and writes the result. Best-effort: failures
4576
+ * are logged but do not propagate.
4623
4577
  */
4624
- function ok(data) {
4625
- return {
4626
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
4627
- };
4628
- }
4629
4578
  /**
4630
- * Format an error tool result.
4579
+ * Read a file's content, returning empty string if the file does not exist.
4631
4580
  *
4632
- * @param error - Error instance, string, or other value.
4633
- * @returns A `ToolResult` with `isError: true`.
4581
+ * @param filePath - Absolute file path.
4582
+ * @returns File content or empty string.
4634
4583
  */
4635
- function fail(error) {
4636
- const message = error instanceof Error ? error.message : String(error);
4637
- return {
4638
- content: [{ type: 'text', text: 'Error: ' + message }],
4639
- isError: true,
4640
- };
4584
+ function readFileOrEmpty(filePath) {
4585
+ try {
4586
+ return readFileSync(filePath, 'utf-8');
4587
+ }
4588
+ catch (err) {
4589
+ if (err instanceof Error &&
4590
+ 'code' in err &&
4591
+ err.code === 'ENOENT') {
4592
+ return '';
4593
+ }
4594
+ throw err;
4595
+ }
4641
4596
  }
4642
4597
  /**
4643
- * Format a connection error with actionable guidance.
4644
- *
4645
- * @remarks
4646
- * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
4647
- * `error.cause.code` and returns a user-friendly message referencing
4648
- * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
4649
- * non-connection errors.
4598
+ * Run a single HEARTBEAT orchestration cycle.
4650
4599
  *
4651
- * @param error - Error instance (typically from `fetch`).
4652
- * @param baseUrl - The URL that was being contacted.
4653
- * @param pluginId - The plugin identifier for config guidance.
4654
- * @returns A `ToolResult` with `isError: true`.
4600
+ * @param options - Heartbeat cycle configuration.
4655
4601
  */
4656
- function connectionFail(error, baseUrl, pluginId) {
4657
- const cause = error instanceof Error ? error.cause : undefined;
4658
- const code = cause && typeof cause === 'object' && 'code' in cause
4659
- ? String(cause.code)
4660
- : '';
4661
- const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
4662
- if (isConnectionError) {
4663
- return {
4664
- content: [
4665
- {
4666
- type: 'text',
4667
- text: [
4668
- `Service not reachable at ${baseUrl}.`,
4669
- 'Either start the service, or if it runs on a different port,',
4670
- `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
4671
- ].join('\n'),
4672
- },
4673
- ],
4674
- isError: true,
4675
- };
4602
+ async function runHeartbeatCycle(options) {
4603
+ const { workspacePath, coreConfigDir, configRoot } = options;
4604
+ const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
4605
+ try {
4606
+ const existingContent = readFileOrEmpty(heartbeatPath);
4607
+ const parsed = parseHeartbeat(existingContent);
4608
+ const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
4609
+ const entries = await orchestrateHeartbeat({
4610
+ coreConfigDir,
4611
+ configRoot,
4612
+ declinedNames,
4613
+ });
4614
+ // Memory hygiene check (Decision 49)
4615
+ if (!declinedNames.has(MEMORY_HEARTBEAT_NAME)) {
4616
+ const wsConfig = loadWorkspaceConfig(workspacePath);
4617
+ const memoryEntry = checkMemoryHealth({
4618
+ workspacePath,
4619
+ budget: wsConfig?.memory?.budget ?? WORKSPACE_CONFIG_DEFAULTS.memory.budget,
4620
+ warningThreshold: wsConfig?.memory?.warningThreshold ??
4621
+ WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
4622
+ staleDays: wsConfig?.memory?.staleDays ??
4623
+ WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
4624
+ });
4625
+ if (memoryEntry)
4626
+ entries.push(memoryEntry);
4627
+ }
4628
+ else {
4629
+ entries.push({
4630
+ name: MEMORY_HEARTBEAT_NAME,
4631
+ declined: true,
4632
+ content: '',
4633
+ });
4634
+ }
4635
+ // Workspace file size health check (Decision 70)
4636
+ const wsFileResults = checkWorkspaceFileHealth({ workspacePath });
4637
+ const wsFileAlerts = workspaceFileHealthEntries(wsFileResults);
4638
+ for (const alert of wsFileAlerts) {
4639
+ if (declinedNames.has(alert.name)) {
4640
+ entries.push({ name: alert.name, declined: true, content: '' });
4641
+ }
4642
+ else {
4643
+ entries.push(alert);
4644
+ }
4645
+ }
4646
+ await writeHeartbeatSection(heartbeatPath, entries);
4647
+ }
4648
+ catch (err) {
4649
+ console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${getErrorMessage(err)}`);
4676
4650
  }
4677
- return fail(error);
4678
4651
  }
4679
4652
 
4680
4653
  /**
4681
- * Factory for the standard plugin tool set.
4654
+ * Timer-based orchestrator for managed content writing.
4682
4655
  *
4683
4656
  * @remarks
4684
- * Produces four standard tools from a component descriptor:
4685
- * - `{name}_status` - Probe service health + version + uptime
4686
- * - `{name}_config` - Query running config with optional JSONPath
4687
- * - `{name}_config_apply` - Push config patch to running service
4688
- * - `{name}_service` - Service lifecycle management
4689
- *
4690
- * Components add domain-specific tools separately.
4657
+ * `ComponentWriter` manages a component's TOOLS.md section writes
4658
+ * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
4659
+ * on a configurable prime-interval timer cycle.
4691
4660
  */
4692
- /** Timeout for HTTP probes in milliseconds. */
4693
- const PROBE_TIMEOUT_MS = 5000;
4694
4661
  /**
4695
- * Create the standard plugin tool set from a component descriptor.
4662
+ * Orchestrates managed content writing for a single Jeeves component.
4696
4663
  *
4697
- * @param descriptor - The component descriptor.
4698
- * @returns Array of tool descriptors to register.
4664
+ * @remarks
4665
+ * Created via {@link createComponentWriter}. Manages a timer that fires
4666
+ * at the component's prime-interval, calling `generateToolsContent()`
4667
+ * and `refreshPlatformContent()` on each cycle.
4699
4668
  */
4700
- function createPluginToolset(descriptor) {
4701
- const { name, defaultPort } = descriptor;
4702
- const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
4703
- const svcManager = createServiceManager(descriptor);
4704
- const statusTool = {
4705
- name: `${name}_status`,
4706
- description: `Get ${name} service health, version, and uptime.`,
4707
- parameters: {
4708
- type: 'object',
4709
- properties: {},
4710
- },
4711
- execute: async () => {
4712
- try {
4713
- const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS);
4714
- if (!res.ok) {
4715
- return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
4716
- }
4717
- const data = await res.json();
4718
- return ok(data);
4719
- }
4720
- catch (err) {
4721
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
4722
- }
4723
- },
4724
- };
4725
- const configTool = {
4726
- name: `${name}_config`,
4727
- description: `Query ${name} running configuration. Optional JSONPath filter.`,
4728
- parameters: {
4729
- type: 'object',
4730
- properties: {
4731
- path: {
4732
- type: 'string',
4733
- description: 'JSONPath expression (optional)',
4734
- },
4735
- },
4736
- },
4737
- execute: async (_id, params) => {
4738
- const path = params.path;
4739
- const qs = path ? `?path=${encodeURIComponent(path)}` : '';
4740
- try {
4741
- const result = await fetchJson(`${baseUrl}/config${qs}`);
4742
- return ok(result);
4743
- }
4744
- catch (err) {
4745
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
4746
- }
4747
- },
4748
- };
4749
- const configApplyTool = {
4750
- name: `${name}_config_apply`,
4751
- description: `Apply a config patch to the running ${name} service.`,
4752
- parameters: {
4753
- type: 'object',
4754
- properties: {
4755
- config: {
4756
- type: 'object',
4757
- description: 'Config patch to apply',
4758
- },
4759
- },
4760
- required: ['config'],
4761
- },
4762
- execute: async (_id, params) => {
4763
- const config = params.config;
4764
- if (!config) {
4765
- return fail('Missing required parameter: config');
4766
- }
4767
- try {
4768
- const result = await postJson(`${baseUrl}/config/apply`, {
4769
- patch: config,
4770
- });
4771
- return ok(result);
4772
- }
4773
- catch (err) {
4774
- return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
4775
- }
4776
- },
4777
- };
4778
- const serviceTool = {
4779
- name: `${name}_service`,
4780
- description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
4781
- parameters: {
4782
- type: 'object',
4783
- properties: {
4784
- action: {
4785
- type: 'string',
4786
- enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
4787
- description: 'Service action to perform',
4788
- },
4789
- },
4790
- required: ['action'],
4791
- },
4792
- execute: (_id, params) => {
4793
- const action = params.action;
4794
- const validActions = [
4795
- 'install',
4796
- 'uninstall',
4797
- 'start',
4798
- 'stop',
4799
- 'restart',
4800
- 'status',
4801
- ];
4802
- if (!validActions.includes(action)) {
4803
- return Promise.resolve(fail(`Invalid action: ${action}`));
4804
- }
4805
- try {
4806
- if (action === 'status') {
4807
- const state = svcManager.status();
4808
- return Promise.resolve(ok({ service: name, state }));
4809
- }
4810
- // Call the appropriate method
4811
- const methodMap = {
4812
- install: () => {
4813
- svcManager.install();
4814
- },
4815
- uninstall: () => {
4816
- svcManager.uninstall();
4817
- },
4818
- start: () => {
4819
- svcManager.start();
4820
- },
4821
- stop: () => {
4822
- svcManager.stop();
4669
+ class ComponentWriter {
4670
+ timer;
4671
+ jitterTimeout;
4672
+ component;
4673
+ configDir;
4674
+ gatewayUrl;
4675
+ pendingCleanups = new Set();
4676
+ /** @internal */
4677
+ constructor(component, options) {
4678
+ this.component = component;
4679
+ this.configDir = getComponentConfigDir(component.name);
4680
+ this.gatewayUrl = options?.gatewayUrl;
4681
+ }
4682
+ /** The component's config directory path. */
4683
+ get componentConfigDir() {
4684
+ return this.configDir;
4685
+ }
4686
+ /** Whether the writer timer is currently running or pending its first cycle. */
4687
+ get isRunning() {
4688
+ return this.jitterTimeout !== undefined || this.timer !== undefined;
4689
+ }
4690
+ /**
4691
+ * Start the writer timer.
4692
+ *
4693
+ * @remarks
4694
+ * Delays the first cycle by a random jitter (0 to one full interval) to
4695
+ * spread initial writes across all component plugins and reduce EPERM
4696
+ * contention on startup.
4697
+ */
4698
+ start() {
4699
+ if (this.isRunning)
4700
+ return;
4701
+ // Random jitter up to one full interval to spread initial writes
4702
+ const intervalMs = this.component.refreshIntervalSeconds * 1000;
4703
+ const jitterMs = Math.floor(Math.random() * intervalMs);
4704
+ this.jitterTimeout = setTimeout(() => {
4705
+ this.jitterTimeout = undefined;
4706
+ void this.cycle();
4707
+ this.timer = setInterval(() => void this.cycle(), intervalMs);
4708
+ }, jitterMs);
4709
+ }
4710
+ /** Stop the writer timer. */
4711
+ stop() {
4712
+ if (this.jitterTimeout) {
4713
+ clearTimeout(this.jitterTimeout);
4714
+ this.jitterTimeout = undefined;
4715
+ }
4716
+ if (this.timer) {
4717
+ clearInterval(this.timer);
4718
+ this.timer = undefined;
4719
+ }
4720
+ }
4721
+ /**
4722
+ * Execute a single write cycle.
4723
+ *
4724
+ * @remarks
4725
+ * 1. Write the component's TOOLS.md section.
4726
+ * 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
4727
+ * 3. Scan for cleanup flags and escalate if a gateway URL is configured.
4728
+ * 4. Run HEARTBEAT health orchestration.
4729
+ */
4730
+ async cycle() {
4731
+ try {
4732
+ const workspacePath = getWorkspacePath();
4733
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
4734
+ // 1. Write the component's TOOLS.md section
4735
+ const toolsContent = this.component.generateToolsContent();
4736
+ await updateManagedSection(toolsPath, toolsContent, {
4737
+ mode: 'section',
4738
+ sectionId: this.component.sectionId,
4739
+ markers: TOOLS_MARKERS,
4740
+ coreVersion: CORE_VERSION,
4741
+ });
4742
+ // 2. Platform content maintenance
4743
+ await refreshPlatformContent({
4744
+ coreVersion: CORE_VERSION,
4745
+ componentName: this.component.name,
4746
+ componentVersion: this.component.version,
4747
+ servicePackage: this.component.servicePackage,
4748
+ pluginPackage: this.component.pluginPackage,
4749
+ });
4750
+ // 3. Cleanup escalation
4751
+ if (this.gatewayUrl) {
4752
+ scanAndEscalateCleanup([
4753
+ { filePath: toolsPath, markerIdentity: 'TOOLS' },
4754
+ {
4755
+ filePath: join(workspacePath, WORKSPACE_FILES.soul),
4756
+ markerIdentity: 'SOUL',
4823
4757
  },
4824
- restart: () => {
4825
- svcManager.restart();
4758
+ {
4759
+ filePath: join(workspacePath, WORKSPACE_FILES.agents),
4760
+ markerIdentity: 'AGENTS',
4826
4761
  },
4827
- };
4828
- methodMap[action]();
4829
- return Promise.resolve(ok({ service: name, action, success: true }));
4830
- }
4831
- catch (err) {
4832
- return Promise.resolve(fail(`Service ${action} failed: ${getErrorMessage(err)}`));
4762
+ ], this.gatewayUrl, this.pendingCleanups);
4833
4763
  }
4834
- },
4835
- };
4836
- return [statusTool, configTool, configApplyTool, serviceTool];
4764
+ // 4. HEARTBEAT orchestration
4765
+ await runHeartbeatCycle({
4766
+ workspacePath,
4767
+ coreConfigDir: getCoreConfigDir(),
4768
+ configRoot: getConfigRoot(),
4769
+ });
4770
+ }
4771
+ catch (err) {
4772
+ console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${getErrorMessage(err)}`);
4773
+ }
4774
+ }
4837
4775
  }
4838
4776
 
4839
4777
  /**
4840
- * Resolve the version of a package from its `import.meta.url`.
4778
+ * Creates a synchronous content accessor backed by an async data source.
4841
4779
  *
4842
- * @module
4780
+ * @remarks
4781
+ * Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
4782
+ * the interface is synchronous, but most components fetch live data from
4783
+ * their HTTP service. This utility returns a sync `() => string` that
4784
+ * serves the last successfully fetched value while kicking off a background
4785
+ * refresh on each call.
4786
+ *
4787
+ * First call returns `placeholder`. Subsequent calls return the last
4788
+ * successfully fetched content. If a refresh fails, the previous good
4789
+ * value is retained.
4790
+ *
4791
+ * @example
4792
+ * ```typescript
4793
+ * const getContent = createAsyncContentCache({
4794
+ * fetch: async () => {
4795
+ * const res = await fetch('http://127.0.0.1:1936/status');
4796
+ * return formatWatcherStatus(await res.json());
4797
+ * },
4798
+ * placeholder: '> Initializing watcher status...',
4799
+ * });
4800
+ *
4801
+ * const writer = createComponentWriter({
4802
+ * // ...
4803
+ * generateToolsContent: getContent,
4804
+ * });
4805
+ * ```
4843
4806
  */
4844
4807
  /**
4845
- * Get the version string from the nearest `package.json` relative to the
4846
- * caller's module URL.
4808
+ * Creates a synchronous content accessor backed by an async data source.
4847
4809
  *
4848
- * @param importMetaUrl - The `import.meta.url` of the calling module.
4849
- * @returns The `version` field, or `'unknown'` on any error.
4810
+ * @param options - Cache configuration.
4811
+ * @returns A sync `() => string` suitable for `generateToolsContent`.
4850
4812
  */
4851
- function getPackageVersion(importMetaUrl) {
4852
- try {
4853
- const dir = fileURLToPath(importMetaUrl);
4854
- const pkgRoot = packageDirectorySync({ cwd: dir });
4855
- if (!pkgRoot)
4856
- return 'unknown';
4857
- const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf-8');
4858
- const pkg = JSON.parse(raw);
4859
- return typeof pkg.version === 'string' ? pkg.version : 'unknown';
4860
- }
4861
- catch {
4862
- return 'unknown';
4863
- }
4813
+ function createAsyncContentCache(options) {
4814
+ const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
4815
+ console.warn('[jeeves] async content cache refresh failed:', err);
4816
+ }, } = options;
4817
+ let cached = placeholder;
4818
+ let refreshing = false;
4819
+ return () => {
4820
+ if (!refreshing) {
4821
+ refreshing = true;
4822
+ fetchContent()
4823
+ .then((content) => {
4824
+ cached = content;
4825
+ })
4826
+ .catch(onError)
4827
+ .finally(() => {
4828
+ refreshing = false;
4829
+ });
4830
+ }
4831
+ return cached;
4832
+ };
4864
4833
  }
4865
4834
 
4866
4835
  /**
4867
- * Plugin resolution helpers for the OpenClaw plugin SDK.
4836
+ * Factory function for creating a ComponentWriter from a descriptor.
4868
4837
  *
4869
4838
  * @remarks
4870
- * Provides workspace path resolution and plugin setting resolution
4871
- * with a standard three-step fallback chain:
4872
- * plugin config → environment variable → default value.
4839
+ * Validates the descriptor via Zod schema and creates a ComponentWriter.
4840
+ * Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
4841
+ * `JeevesComponent` interface is no longer accepted.
4873
4842
  */
4874
4843
  /**
4875
- * Resolve the workspace root from the OpenClaw plugin API.
4844
+ * Create a ComponentWriter for a validated component descriptor.
4876
4845
  *
4877
4846
  * @remarks
4878
- * Tries three sources in order:
4879
- * 1. `api.config.agents.defaults.workspace` — explicit config
4880
- * 2. `api.resolvePath('.')` — gateway-provided path resolver
4881
- * 3. `process.cwd()` — last resort
4847
+ * The descriptor is validated via the Zod schema at runtime.
4848
+ * This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
4882
4849
  *
4883
- * @param api - The plugin API object provided by the gateway.
4884
- * @returns Absolute path to the workspace root.
4850
+ * @param descriptor - The component descriptor to validate and wrap.
4851
+ * @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
4852
+ * @returns A new `ComponentWriter` instance.
4853
+ * @throws ZodError if the descriptor is invalid.
4885
4854
  */
4886
- function resolveWorkspacePath(api) {
4887
- const configured = api.config?.agents?.defaults?.workspace;
4888
- if (typeof configured === 'string' && configured.trim()) {
4889
- return configured;
4855
+ function createComponentWriter(descriptor, options) {
4856
+ // Validate via Zod — throws ZodError with detailed messages on failure
4857
+ jeevesComponentDescriptorSchema.parse(descriptor);
4858
+ return new ComponentWriter(descriptor, options);
4859
+ }
4860
+
4861
+ /**
4862
+ * Resolve the bind address for a Jeeves service.
4863
+ *
4864
+ * @remarks
4865
+ * Resolution order (four-tier):
4866
+ * 1. Component config `bindAddress` field (if componentName provided)
4867
+ * 2. Core config `bindAddress` field
4868
+ * 3. `JEEVES_BIND_ADDRESS` environment variable
4869
+ * 4. Default: `0.0.0.0`
4870
+ */
4871
+ /**
4872
+ * Resolve the bind address for a Jeeves service.
4873
+ *
4874
+ * @param componentName - Optional component name for component-specific override.
4875
+ * @returns The resolved bind address.
4876
+ */
4877
+ function getBindAddress(componentName) {
4878
+ // Tier 1: Component config (if provided)
4879
+ if (componentName) {
4880
+ const componentConfig = loadConfig(getComponentConfigDir(componentName));
4881
+ if (componentConfig?.bindAddress) {
4882
+ return componentConfig.bindAddress;
4883
+ }
4890
4884
  }
4891
- if (typeof api.resolvePath === 'function') {
4892
- return api.resolvePath('.');
4885
+ // Tier 2: Core config
4886
+ const coreConfig = loadConfig(getCoreConfigDir());
4887
+ if (coreConfig?.bindAddress) {
4888
+ return coreConfig.bindAddress;
4893
4889
  }
4894
- return process.cwd();
4890
+ // Tier 3: Environment variable
4891
+ const envValue = process.env['JEEVES_BIND_ADDRESS'];
4892
+ if (envValue) {
4893
+ return envValue;
4894
+ }
4895
+ // Tier 4: Default
4896
+ return DEFAULT_BIND_ADDRESS;
4895
4897
  }
4898
+
4896
4899
  /**
4897
- * Resolve a plugin setting via the standard three-step fallback chain:
4898
- * plugin config → environment variable → fallback value.
4900
+ * One-shot content seeding used by the CLI install command.
4899
4901
  *
4900
- * @param api - Plugin API object.
4901
- * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
4902
- * @param key - Config key within the plugin's config object.
4903
- * @param envVar - Environment variable name.
4904
- * @param fallback - Default value if neither source provides one.
4905
- * @returns The resolved setting value.
4902
+ * @remarks
4903
+ * Seeds SOUL.md, AGENTS.md, and TOOLS.md Platform section using the same
4904
+ * `updateManagedSection()` code path as writer cycles. Also copies templates
4905
+ * and creates core config with defaults if missing.
4906
4906
  */
4907
- function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
4908
- const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
4909
- if (typeof fromPlugin === 'string')
4910
- return fromPlugin;
4911
- const fromEnv = process.env[envVar];
4912
- if (fromEnv)
4913
- return fromEnv;
4914
- return fallback;
4907
+ /**
4908
+ * Create the core config file with defaults if it doesn't already exist.
4909
+ *
4910
+ * @param coreConfigDir - Path to the core config directory.
4911
+ */
4912
+ function ensureCoreConfig(coreConfigDir) {
4913
+ if (!existsSync(coreConfigDir)) {
4914
+ mkdirSync(coreConfigDir, { recursive: true });
4915
+ }
4916
+ const configPath = join(coreConfigDir, CONFIG_FILE);
4917
+ if (existsSync(configPath))
4918
+ return;
4919
+ const defaults = coreConfigSchema.parse({});
4920
+ const configWithSchema = {
4921
+ $schema: './config.schema.json',
4922
+ ...defaults,
4923
+ };
4924
+ writeFileSync(configPath, JSON.stringify(configWithSchema, null, 2), 'utf-8');
4925
+ // Write JSON schema file alongside config
4926
+ const schemaPath = join(coreConfigDir, 'config.schema.json');
4927
+ const jsonSchema = generateJsonSchema();
4928
+ writeFileSync(schemaPath, JSON.stringify(jsonSchema, null, 2), 'utf-8');
4915
4929
  }
4916
4930
  /**
4917
- * Resolve an optional plugin setting via the two-step fallback chain:
4918
- * plugin config → environment variable. Returns `undefined` if neither
4919
- * source provides a value.
4931
+ * Seed all platform content into the workspace.
4920
4932
  *
4921
- * @param api - Plugin API object.
4922
- * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
4923
- * @param key - Config key within the plugin's config object.
4924
- * @param envVar - Environment variable name.
4925
- * @returns The resolved setting value, or `undefined`.
4933
+ * @remarks
4934
+ * Uses the same `updateManagedSection()` code path as writer cycles.
4935
+ * Creates core config with defaults if missing. Copies templates.
4936
+ * Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
4937
+ * Jaccard cleanup detection runs automatically via `updateManagedSection`.
4938
+ *
4939
+ * @param options - Seeding configuration.
4926
4940
  */
4927
- function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
4928
- const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
4929
- if (typeof fromPlugin === 'string')
4930
- return fromPlugin;
4931
- const fromEnv = process.env[envVar];
4932
- if (fromEnv)
4933
- return fromEnv;
4934
- return undefined;
4941
+ async function seedContent(options) {
4942
+ const coreConfigDir = getCoreConfigDir();
4943
+ // Ensure core config exists
4944
+ ensureCoreConfig(coreConfigDir);
4945
+ // Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
4946
+ await refreshPlatformContent({
4947
+ coreVersion: options.coreVersion,
4948
+ });
4949
+ // Seed HEARTBEAT.md with "Not installed" alerts for all platform components
4950
+ const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
4951
+ const entries = PLATFORM_COMPONENTS.map((name) => ({
4952
+ name: toServiceName(name),
4953
+ declined: false,
4954
+ content: `- ${NOT_INSTALLED_ALERTS[name]}`,
4955
+ }));
4956
+ await writeHeartbeatSection(heartbeatPath, entries);
4957
+ // Seed jeeves workspace skill (Decision 48: overwrite-on-install)
4958
+ seedSkill(getWorkspacePath());
4935
4959
  }
4936
4960
 
4937
4961
  /**
@@ -5308,4 +5332,4 @@ async function getChannelWorkspace(channelId, token, options) {
5308
5332
  return teamId;
5309
5333
  }
5310
5334
 
5311
- export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_BIND_ADDRESS, DEFAULT_CORE_VERSION, DEFAULT_PORTS, HEARTBEAT_HEADING, JEEVES_SKILL_DIR, MEMORY_HEARTBEAT_NAME, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SKILLS_DIR, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_CONFIG_DEFAULTS, WORKSPACE_CONFIG_FILE, WORKSPACE_FILES, analyzeMemory, appendJsonl, atomicWrite, buildEffectiveConfig, buildHeartbeatSection, checkMemoryHealth, checkNodeVersion, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigApplyHandler, createConfigQueryHandler, createGoogleAuth, createPluginCli, createPluginToolset, createServiceCli, createServiceManager, createStatusHandler, ensureDir, extractMostRecentDate, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, generateWorkspaceJsonSchema, getArg, getBindAddress, getChannelWorkspace, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getEffectiveServiceName, getPackageVersion, getServiceState, getServiceUrl, getWorkspacePath, init, isPrime, jaccard, jeevesComponentDescriptorSchema, loadEnvFile, loadWorkspaceConfig, needsCleanup, nowIso, ok, orchestrateHeartbeat, parseArgs, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, readJson, readJsonl, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveConfigValue, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, run, runScript, runWithRetry, saveCache, seedContent, seedSkill, shingles, shouldWrite, sleepAsync, sleepMs, updateManagedSection, uuid, withFileLock, workspaceConfigSchema, writeComponentVersion, writeHeartbeatSection, writeJsonAtomic, writeJsonl };
5335
+ export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_BIND_ADDRESS, DEFAULT_CORE_VERSION, DEFAULT_PORTS, HEARTBEAT_HEADING, JEEVES_SKILL_DIR, MEMORY_HEARTBEAT_NAME, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SKILLS_DIR, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_CONFIG_DEFAULTS, WORKSPACE_CONFIG_FILE, WORKSPACE_FILES, analyzeMemory, appendJsonl, atomicWrite, buildEffectiveConfig, buildHeartbeatSection, checkMemoryHealth, checkNodeVersion, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigApplyHandler, createConfigQueryHandler, createGoogleAuth, createPluginCli, createPluginToolset, createServiceCli, createServiceManager, createStatusHandler, ensureDir, extractMostRecentDate, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, generateWorkspaceJsonSchema, getArg, getBindAddress, getChannelWorkspace, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getEffectiveServiceName, getPackageRoot, getPackageVersion, getServiceState, getServiceUrl, getWorkspacePath, init, isPrime, jaccard, jeevesComponentDescriptorSchema, loadEnvFile, loadWorkspaceConfig, needsCleanup, nowIso, ok, orchestrateHeartbeat, parseArgs, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, readJson, readJsonl, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveConfigValue, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, run, runScript, runWithRetry, saveCache, seedContent, seedSkill, shingles, shouldWrite, sleepAsync, sleepMs, updateManagedSection, uuid, withFileLock, workspaceConfigSchema, writeComponentVersion, writeHeartbeatSection, writeJsonAtomic, writeJsonl };