@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/cli/jeeves/index.js +2 -2
- package/dist/cli/plugin/index.js +166 -11
- package/dist/index.d.ts +16 -3
- package/dist/index.js +2274 -2250
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
*
|
|
1569
|
+
* Zod schema for the Jeeves component descriptor.
|
|
1570
1570
|
*
|
|
1571
1571
|
* @remarks
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
|
|
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
|
-
* @
|
|
1697
|
+
* @param descriptor - The component descriptor.
|
|
1698
|
+
* @returns The service name (explicit or derived from `jeeves-{name}`).
|
|
1585
1699
|
*/
|
|
1586
|
-
function
|
|
1587
|
-
|
|
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
|
-
*
|
|
1705
|
+
* Platform-aware service state detection.
|
|
1597
1706
|
*
|
|
1598
1707
|
* @remarks
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
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
|
|
1603
|
-
* @returns
|
|
1714
|
+
* @param serviceName - The service name (e.g., 'jeeves-runner').
|
|
1715
|
+
* @returns The detected service state.
|
|
1604
1716
|
*/
|
|
1605
|
-
function
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
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
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
if (
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
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
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
if (!plugins.entries || typeof plugins.entries !== 'object') {
|
|
1664
|
-
plugins.entries = {};
|
|
1765
|
+
catch {
|
|
1766
|
+
return 'not_installed';
|
|
1665
1767
|
}
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
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
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
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
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
1739
|
-
|
|
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
|
-
*
|
|
1825
|
+
* Factory for platform-aware service lifecycle management.
|
|
1745
1826
|
*
|
|
1746
|
-
* @
|
|
1747
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
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
|
-
*
|
|
1855
|
+
* Resolve the effective service name from options and descriptor.
|
|
1761
1856
|
*
|
|
1762
|
-
* @
|
|
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
|
-
*
|
|
1865
|
+
* Resolve the config path for install.
|
|
1766
1866
|
*
|
|
1767
|
-
* @param
|
|
1768
|
-
* @
|
|
1867
|
+
* @param descriptor - Component descriptor.
|
|
1868
|
+
* @param options - Optional overrides.
|
|
1869
|
+
* @returns Absolute config file path.
|
|
1769
1870
|
*/
|
|
1770
|
-
function
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
const
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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
|
-
*
|
|
2137
|
+
* Tool result formatters for the OpenClaw plugin SDK.
|
|
1968
2138
|
*
|
|
1969
2139
|
* @remarks
|
|
1970
|
-
*
|
|
1971
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1978
|
-
* @
|
|
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
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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
|
|
2202
|
+
return fail(error);
|
|
1992
2203
|
}
|
|
2204
|
+
|
|
1993
2205
|
/**
|
|
1994
|
-
*
|
|
2206
|
+
* Factory for the standard plugin tool set.
|
|
1995
2207
|
*
|
|
1996
2208
|
* @remarks
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
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
|
-
|
|
2002
|
-
|
|
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
|
-
*
|
|
2220
|
+
* Create the standard plugin tool set from a component descriptor.
|
|
2094
2221
|
*
|
|
2095
2222
|
* @param descriptor - The component descriptor.
|
|
2096
|
-
* @returns
|
|
2223
|
+
* @returns Array of tool descriptors to register.
|
|
2097
2224
|
*/
|
|
2098
|
-
function
|
|
2099
|
-
|
|
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
|
-
*
|
|
2365
|
+
* Resolve the package root directory from a module's `import.meta.url`.
|
|
2104
2366
|
*
|
|
2105
|
-
* @
|
|
2106
|
-
* Thin wrappers around `fetch` that throw on non-OK responses
|
|
2107
|
-
* and handle JSON serialisation/deserialisation.
|
|
2367
|
+
* @module
|
|
2108
2368
|
*/
|
|
2109
2369
|
/**
|
|
2110
|
-
*
|
|
2370
|
+
* Get the nearest package root directory relative to the calling module URL.
|
|
2111
2371
|
*
|
|
2112
|
-
* @param
|
|
2113
|
-
* @
|
|
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
|
-
|
|
2118
|
-
const controller = new AbortController();
|
|
2119
|
-
const timeout = setTimeout(() => {
|
|
2120
|
-
controller.abort();
|
|
2121
|
-
}, timeoutMs);
|
|
2375
|
+
function getPackageRoot(importMetaUrl) {
|
|
2122
2376
|
try {
|
|
2123
|
-
return
|
|
2377
|
+
return packageDirectorySync({ cwd: fileURLToPath(importMetaUrl) });
|
|
2124
2378
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2379
|
+
catch {
|
|
2380
|
+
return undefined;
|
|
2127
2381
|
}
|
|
2128
2382
|
}
|
|
2383
|
+
|
|
2129
2384
|
/**
|
|
2130
|
-
*
|
|
2385
|
+
* Resolve the version of a package from its `import.meta.url`.
|
|
2131
2386
|
*
|
|
2132
|
-
* @
|
|
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
|
-
*
|
|
2390
|
+
* Get the version string from the nearest `package.json` relative to the
|
|
2391
|
+
* caller's module URL.
|
|
2146
2392
|
*
|
|
2147
|
-
* @param
|
|
2148
|
-
* @
|
|
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
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
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
|
-
*
|
|
2411
|
+
* OpenClaw configuration helpers for plugin CLI installers.
|
|
2161
2412
|
*
|
|
2162
2413
|
* @remarks
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
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
|
-
*
|
|
2418
|
+
* Resolve the OpenClaw home directory.
|
|
2168
2419
|
*
|
|
2169
|
-
* @
|
|
2170
|
-
*
|
|
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
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
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
|
|
2189
|
-
|
|
2190
|
-
|
|
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
|
-
*
|
|
2209
|
-
*
|
|
2210
|
-
*
|
|
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
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
|
|
2234
|
-
|
|
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
|
-
*
|
|
2239
|
-
*
|
|
2240
|
-
*
|
|
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
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
if (
|
|
2256
|
-
|
|
2257
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2515
|
+
else if (pluginId in entries) {
|
|
2516
|
+
Reflect.deleteProperty(entries, pluginId);
|
|
2517
|
+
messages.push(`Removed "${pluginId}" from plugins.entries`);
|
|
2269
2518
|
}
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
*
|
|
2549
|
+
* Plugin resolution helpers for the OpenClaw plugin SDK.
|
|
2281
2550
|
*
|
|
2282
2551
|
* @remarks
|
|
2283
|
-
*
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
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
|
-
/**
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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
|
-
|
|
2306
|
-
return
|
|
2573
|
+
if (typeof api.resolvePath === 'function') {
|
|
2574
|
+
return api.resolvePath('.');
|
|
2307
2575
|
}
|
|
2576
|
+
return process.cwd();
|
|
2308
2577
|
}
|
|
2309
2578
|
/**
|
|
2310
|
-
* Resolve
|
|
2579
|
+
* Resolve a plugin setting via the standard three-step fallback chain:
|
|
2580
|
+
* plugin config → environment variable → fallback value.
|
|
2311
2581
|
*
|
|
2312
|
-
* @param
|
|
2313
|
-
* @param
|
|
2314
|
-
* @
|
|
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
|
|
2317
|
-
|
|
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
|
|
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
|
|
2323
|
-
* @param
|
|
2324
|
-
* @
|
|
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
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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
|
-
*
|
|
2620
|
+
* Internal helpers for the plugin installer CLI.
|
|
2375
2621
|
*
|
|
2376
|
-
* @
|
|
2377
|
-
* @param cmdArgs - Command + args array.
|
|
2378
|
-
* @returns Unit file content.
|
|
2622
|
+
* @module
|
|
2379
2623
|
*/
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
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
|
-
/**
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
-
*
|
|
2657
|
+
* Read and parse a JSON file, returning an empty object if not found.
|
|
2442
2658
|
*
|
|
2443
|
-
* @param
|
|
2444
|
-
* @
|
|
2445
|
-
* @returns Plist XML content.
|
|
2659
|
+
* @param filePath - Path to the JSON file.
|
|
2660
|
+
* @returns Parsed object.
|
|
2446
2661
|
*/
|
|
2447
|
-
function
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
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
|
-
*
|
|
2673
|
+
* Factory for the standard `-openclaw` plugin installer CLI.
|
|
2515
2674
|
*
|
|
2516
|
-
* @
|
|
2517
|
-
|
|
2518
|
-
|
|
2675
|
+
* @module
|
|
2676
|
+
*/
|
|
2677
|
+
/**
|
|
2678
|
+
* Create a standard plugin installer CLI program.
|
|
2519
2679
|
*
|
|
2520
|
-
* @param
|
|
2521
|
-
* @returns A
|
|
2680
|
+
* @param options - Plugin CLI configuration.
|
|
2681
|
+
* @returns A Commander program ready for `.parse()`.
|
|
2522
2682
|
*/
|
|
2523
|
-
function
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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
|
-
*
|
|
3700
|
+
* Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
|
|
3654
3701
|
*
|
|
3655
3702
|
* @remarks
|
|
3656
|
-
*
|
|
3657
|
-
*
|
|
3658
|
-
*
|
|
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
|
-
*
|
|
3709
|
+
* Resolve the package's content directory for template file copying.
|
|
3694
3710
|
*
|
|
3695
3711
|
* @remarks
|
|
3696
|
-
*
|
|
3697
|
-
*
|
|
3698
|
-
*
|
|
3699
|
-
*
|
|
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
|
-
*
|
|
3720
|
-
*
|
|
3721
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
3760
|
-
|
|
3761
|
-
|
|
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
|
-
*
|
|
3733
|
+
* Copy templates from content/templates/ to the core config directory.
|
|
3779
3734
|
*
|
|
3780
|
-
* @
|
|
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
|
-
|
|
3788
|
-
const
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
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
|
-
*
|
|
3751
|
+
* Render the Platform template using simple string replacement.
|
|
3829
3752
|
*
|
|
3830
|
-
* @
|
|
3753
|
+
* @param templatePath - Path to the templates directory.
|
|
3754
|
+
* @returns Rendered platform content string.
|
|
3831
3755
|
*/
|
|
3832
|
-
function
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
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
|
-
*
|
|
3770
|
+
* Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
|
|
3876
3771
|
*
|
|
3877
|
-
* @param
|
|
3878
|
-
* @returns Parsed config or undefined.
|
|
3772
|
+
* @param options - Configuration for the refresh cycle.
|
|
3879
3773
|
*/
|
|
3880
|
-
function
|
|
3881
|
-
const
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
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
|
-
*
|
|
3820
|
+
* Cleanup-session escalation for managed files with orphaned duplicated content.
|
|
3896
3821
|
*
|
|
3897
3822
|
* @remarks
|
|
3898
|
-
*
|
|
3899
|
-
*
|
|
3900
|
-
*
|
|
3901
|
-
*
|
|
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
|
-
*
|
|
3831
|
+
* Build the cleanup task prompt sent to the gateway session API.
|
|
3906
3832
|
*
|
|
3907
|
-
* @param
|
|
3908
|
-
* @param
|
|
3909
|
-
* @returns
|
|
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
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
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
|
-
|
|
3922
|
-
|
|
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
|
-
*
|
|
3879
|
+
* Cleanup flag scanning extracted from ComponentWriter.cycle().
|
|
3937
3880
|
*
|
|
3938
3881
|
* @remarks
|
|
3939
|
-
*
|
|
3940
|
-
*
|
|
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
|
-
*
|
|
3887
|
+
* Scan managed files for the cleanup flag and escalate when detected.
|
|
3944
3888
|
*
|
|
3945
|
-
* @param
|
|
3946
|
-
* @param
|
|
3947
|
-
* @param
|
|
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
|
|
3951
|
-
const
|
|
3952
|
-
// Check cache first
|
|
3953
|
-
if (existsSync(cachePath)) {
|
|
3893
|
+
function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
|
|
3894
|
+
for (const target of targets) {
|
|
3954
3895
|
try {
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
const
|
|
3958
|
-
if (
|
|
3959
|
-
|
|
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
|
-
//
|
|
3911
|
+
// Best-effort: don't fail the cycle for escalation issues.
|
|
3964
3912
|
}
|
|
3965
3913
|
}
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
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
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
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
|
-
|
|
3987
|
-
|
|
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
|
|
4003
|
+
* HEARTBEAT integration for memory hygiene.
|
|
3993
4004
|
*
|
|
3994
4005
|
* @remarks
|
|
3995
|
-
*
|
|
3996
|
-
*
|
|
3997
|
-
*
|
|
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
|
-
/**
|
|
4000
|
-
|
|
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
|
|
4016
|
+
* Check memory health and return a HEARTBEAT entry if unhealthy.
|
|
4052
4017
|
*
|
|
4053
|
-
* @
|
|
4018
|
+
* @param options - Memory hygiene options (workspacePath, budget, etc.).
|
|
4019
|
+
* @returns A `HeartbeatEntry` when memory needs attention, `undefined` when healthy.
|
|
4054
4020
|
*/
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
return
|
|
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
|
-
|
|
4061
|
-
|
|
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
|
-
*
|
|
4043
|
+
* HEARTBEAT integration for workspace file size monitoring.
|
|
4066
4044
|
*
|
|
4067
|
-
* @
|
|
4068
|
-
*
|
|
4069
|
-
*
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
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
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
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
|
-
*
|
|
4067
|
+
* Check all workspace files against the character budget.
|
|
4119
4068
|
*
|
|
4120
|
-
* @param
|
|
4121
|
-
* @
|
|
4122
|
-
*
|
|
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
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
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
|
-
*
|
|
4103
|
+
* Convert workspace file health results into HEARTBEAT entries.
|
|
4151
4104
|
*
|
|
4152
|
-
* @param
|
|
4153
|
-
* @returns Array of HeartbeatEntry for
|
|
4105
|
+
* @param results - Results from `checkWorkspaceFileHealth`.
|
|
4106
|
+
* @returns Array of `HeartbeatEntry` objects for files that exceed the
|
|
4107
|
+
* warning threshold.
|
|
4154
4108
|
*/
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
}
|
|
4170
|
-
|
|
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
|
-
*
|
|
4128
|
+
* Core configuration schema and resolution.
|
|
4222
4129
|
*
|
|
4223
4130
|
* @remarks
|
|
4224
|
-
*
|
|
4225
|
-
*
|
|
4226
|
-
*
|
|
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
|
-
*
|
|
4178
|
+
* Generate a JSON Schema from the Zod schema for `$schema` pointer support.
|
|
4230
4179
|
*
|
|
4231
|
-
* @
|
|
4232
|
-
* @returns File content or empty string.
|
|
4180
|
+
* @returns A JSON Schema object.
|
|
4233
4181
|
*/
|
|
4234
|
-
function
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
'
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
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
|
-
*
|
|
4225
|
+
* Load and parse a config file, returning undefined if missing or invalid.
|
|
4249
4226
|
*
|
|
4250
|
-
* @param
|
|
4227
|
+
* @param configDir - Directory containing config.json.
|
|
4228
|
+
* @returns Parsed config or undefined.
|
|
4251
4229
|
*/
|
|
4252
|
-
|
|
4253
|
-
const
|
|
4254
|
-
|
|
4230
|
+
function loadConfig(configDir) {
|
|
4231
|
+
const configPath = join(configDir, CONFIG_FILE);
|
|
4232
|
+
if (!existsSync(configPath))
|
|
4233
|
+
return undefined;
|
|
4255
4234
|
try {
|
|
4256
|
-
const
|
|
4257
|
-
const parsed =
|
|
4258
|
-
|
|
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
|
|
4299
|
-
|
|
4239
|
+
catch {
|
|
4240
|
+
return undefined;
|
|
4300
4241
|
}
|
|
4301
4242
|
}
|
|
4302
4243
|
|
|
4303
4244
|
/**
|
|
4304
|
-
*
|
|
4245
|
+
* Service URL resolution.
|
|
4305
4246
|
*
|
|
4306
4247
|
* @remarks
|
|
4307
|
-
*
|
|
4308
|
-
*
|
|
4309
|
-
*
|
|
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
|
-
*
|
|
4313
|
-
*
|
|
4314
|
-
* @
|
|
4315
|
-
*
|
|
4316
|
-
*
|
|
4317
|
-
*
|
|
4318
|
-
*/
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
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
|
-
*
|
|
4286
|
+
* Registry version cache for npm package update awareness.
|
|
4429
4287
|
*
|
|
4430
4288
|
* @remarks
|
|
4431
|
-
*
|
|
4432
|
-
*
|
|
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
|
-
*
|
|
4293
|
+
* Check the npm registry for the latest version of a package.
|
|
4459
4294
|
*
|
|
4460
|
-
* @param
|
|
4461
|
-
* @
|
|
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
|
|
4464
|
-
const
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
4498
|
-
*
|
|
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
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
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
|
-
*
|
|
4401
|
+
* Check if Qdrant is reachable (watcher dependency).
|
|
4513
4402
|
*
|
|
4514
|
-
* @
|
|
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
|
-
*
|
|
4415
|
+
* Determine the state of a single component.
|
|
4523
4416
|
*
|
|
4524
|
-
* @param
|
|
4525
|
-
* @
|
|
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
|
|
4528
|
-
//
|
|
4529
|
-
if (
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
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
|
-
//
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
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
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
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
|
-
*
|
|
4468
|
+
* Generate the alert text for a component in a given state.
|
|
4559
4469
|
*
|
|
4560
|
-
* @param
|
|
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
|
|
4563
|
-
if (
|
|
4564
|
-
|
|
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
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
const
|
|
4571
|
-
|
|
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
|
-
*
|
|
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 -
|
|
4502
|
+
* @param options - Orchestration configuration.
|
|
4503
|
+
* @returns Array of HeartbeatEntry for writeHeartbeatSection.
|
|
4590
4504
|
*/
|
|
4591
|
-
async function
|
|
4592
|
-
const coreConfigDir =
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
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
|
-
*
|
|
4571
|
+
* HEARTBEAT orchestration extracted from ComponentWriter.cycle().
|
|
4613
4572
|
*
|
|
4614
4573
|
* @remarks
|
|
4615
|
-
*
|
|
4616
|
-
*
|
|
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
|
-
*
|
|
4579
|
+
* Read a file's content, returning empty string if the file does not exist.
|
|
4631
4580
|
*
|
|
4632
|
-
* @param
|
|
4633
|
-
* @returns
|
|
4581
|
+
* @param filePath - Absolute file path.
|
|
4582
|
+
* @returns File content or empty string.
|
|
4634
4583
|
*/
|
|
4635
|
-
function
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
4657
|
-
const
|
|
4658
|
-
const
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
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
|
-
*
|
|
4654
|
+
* Timer-based orchestrator for managed content writing.
|
|
4682
4655
|
*
|
|
4683
4656
|
* @remarks
|
|
4684
|
-
*
|
|
4685
|
-
*
|
|
4686
|
-
*
|
|
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
|
-
*
|
|
4662
|
+
* Orchestrates managed content writing for a single Jeeves component.
|
|
4696
4663
|
*
|
|
4697
|
-
* @
|
|
4698
|
-
* @
|
|
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
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
const
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
}
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
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
|
-
|
|
4825
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4778
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
4841
4779
|
*
|
|
4842
|
-
* @
|
|
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
|
-
*
|
|
4846
|
-
* caller's module URL.
|
|
4808
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
4847
4809
|
*
|
|
4848
|
-
* @param
|
|
4849
|
-
* @returns
|
|
4810
|
+
* @param options - Cache configuration.
|
|
4811
|
+
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
4850
4812
|
*/
|
|
4851
|
-
function
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
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
|
-
*
|
|
4836
|
+
* Factory function for creating a ComponentWriter from a descriptor.
|
|
4868
4837
|
*
|
|
4869
4838
|
* @remarks
|
|
4870
|
-
*
|
|
4871
|
-
*
|
|
4872
|
-
*
|
|
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
|
-
*
|
|
4844
|
+
* Create a ComponentWriter for a validated component descriptor.
|
|
4876
4845
|
*
|
|
4877
4846
|
* @remarks
|
|
4878
|
-
*
|
|
4879
|
-
*
|
|
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
|
|
4884
|
-
* @
|
|
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
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
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
|
-
|
|
4892
|
-
|
|
4885
|
+
// Tier 2: Core config
|
|
4886
|
+
const coreConfig = loadConfig(getCoreConfigDir());
|
|
4887
|
+
if (coreConfig?.bindAddress) {
|
|
4888
|
+
return coreConfig.bindAddress;
|
|
4893
4889
|
}
|
|
4894
|
-
|
|
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
|
-
*
|
|
4898
|
-
* plugin config → environment variable → fallback value.
|
|
4900
|
+
* One-shot content seeding used by the CLI install command.
|
|
4899
4901
|
*
|
|
4900
|
-
* @
|
|
4901
|
-
*
|
|
4902
|
-
*
|
|
4903
|
-
*
|
|
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
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
4922
|
-
*
|
|
4923
|
-
*
|
|
4924
|
-
*
|
|
4925
|
-
*
|
|
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
|
|
4928
|
-
const
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
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 };
|