@karmaniverous/jeeves 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,11 +2,11 @@ import { JSONPath } from 'jsonpath-plus';
2
2
  import { writeFileSync, renameSync, existsSync, readFileSync, mkdirSync, cpSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { lock } from 'proper-lockfile';
5
- import { gte } from 'semver';
5
+ import { gte, gt } from 'semver';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { packageDirectorySync } from 'package-directory';
8
- import { z } from 'zod';
9
8
  import { execSync } from 'node:child_process';
9
+ import { z } from 'zod';
10
10
  import { homedir } from 'node:os';
11
11
 
12
12
  /**
@@ -66,6 +66,8 @@ const WORKSPACE_FILES = {
66
66
  soul: 'SOUL.md',
67
67
  /** AGENTS.md — operational protocols and memory architecture. */
68
68
  agents: 'AGENTS.md',
69
+ /** HEARTBEAT.md — platform status and health alerts. */
70
+ heartbeat: 'HEARTBEAT.md',
69
71
  };
70
72
  /** Templates directory name within core config. */
71
73
  const TEMPLATES_DIR = 'templates';
@@ -80,14 +82,14 @@ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
80
82
  * Core library version, inlined at build time.
81
83
  *
82
84
  * @remarks
83
- * The `0.3.0` placeholder is replaced by
85
+ * The `0.3.1` placeholder is replaced by
84
86
  * `@rollup/plugin-replace` during the build with the actual version
85
87
  * from `package.json`. This ensures the correct version survives
86
88
  * when consumers bundle core into their own dist (where runtime
87
89
  * `import.meta.url`-based resolution would find the wrong package.json).
88
90
  */
89
91
  /** The core library version from package.json (inlined at build time). */
90
- const CORE_VERSION = '0.3.0';
92
+ const CORE_VERSION = '0.3.1';
91
93
 
92
94
  /**
93
95
  * Shared file I/O helpers for managed section operations.
@@ -198,6 +200,25 @@ function writeComponentVersion(coreConfigDir, options) {
198
200
  }
199
201
  atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
200
202
  }
203
+ /**
204
+ * Remove a component's version entry from the shared state file.
205
+ *
206
+ * @remarks
207
+ * Called during plugin uninstall to prevent the HEARTBEAT writer from
208
+ * probing a service that's intentionally gone. If the component isn't
209
+ * in the file, this is a no-op.
210
+ *
211
+ * @param coreConfigDir - Path to the core config directory.
212
+ * @param componentName - The component name to remove.
213
+ */
214
+ function removeComponentVersion(coreConfigDir, componentName) {
215
+ const existing = readComponentVersions(coreConfigDir);
216
+ if (!(componentName in existing))
217
+ return;
218
+ const updated = Object.fromEntries(Object.entries(existing).filter(([key]) => key !== componentName));
219
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
220
+ atomicWrite(filePath, JSON.stringify(updated, null, 2) + '\n');
221
+ }
201
222
 
202
223
  /**
203
224
  * Comment markers for managed content blocks.
@@ -216,6 +237,8 @@ const TOOLS_MARKERS = {
216
237
  end: 'END JEEVES PLATFORM TOOLS',
217
238
  /** H1 title prepended in section mode. */
218
239
  title: 'Jeeves Platform Tools',
240
+ /** Managed block at bottom of file. */
241
+ position: 'bottom',
219
242
  };
220
243
  /** Default markers for SOUL.md managed block. */
221
244
  const SOUL_MARKERS = {
@@ -225,6 +248,8 @@ const SOUL_MARKERS = {
225
248
  end: 'END JEEVES SOUL',
226
249
  /** H1 title prepended in the managed block. */
227
250
  title: 'Jeeves Platform Soul',
251
+ /** Managed block at bottom of file. */
252
+ position: 'bottom',
228
253
  };
229
254
  /** Default markers for AGENTS.md managed block. */
230
255
  const AGENTS_MARKERS = {
@@ -234,6 +259,8 @@ const AGENTS_MARKERS = {
234
259
  end: 'END JEEVES AGENTS',
235
260
  /** H1 title prepended in the managed block. */
236
261
  title: 'Jeeves Platform Agents',
262
+ /** Managed block at bottom of file. */
263
+ position: 'bottom',
237
264
  };
238
265
  /** All known marker sets — single source of truth for cross-contamination detection. */
239
266
  const ALL_MARKERS = [
@@ -281,7 +308,7 @@ const DEFAULT_PORTS = {
281
308
  };
282
309
 
283
310
  /**
284
- * Managed section IDs and their stable ordering for TOOLS.md.
311
+ * Managed section IDs, stable ordering, and platform component registry.
285
312
  *
286
313
  * @remarks
287
314
  * Section ordering is fixed to prevent diff churn regardless of which
@@ -311,6 +338,22 @@ const SECTION_ORDER = [
311
338
  SECTION_IDS.Runner,
312
339
  SECTION_IDS.Meta,
313
340
  ];
341
+ /**
342
+ * The four essential platform components.
343
+ *
344
+ * @remarks
345
+ * These components constitute the Jeeves platform. `jeeves install` writes
346
+ * initial HEARTBEAT "Not installed" alerts for all of them. The HEARTBEAT
347
+ * writer generates "Not installed" alerts only for platform components not
348
+ * in `component-versions.json`. Optional future components (not in this list)
349
+ * appear in HEARTBEAT only after explicit install.
350
+ */
351
+ const PLATFORM_COMPONENTS = [
352
+ 'runner',
353
+ 'watcher',
354
+ 'server',
355
+ 'meta',
356
+ ];
314
357
 
315
358
  /**
316
359
  * Workspace and config root initialization.
@@ -394,6 +437,124 @@ function resetInit() {
394
437
  state = undefined;
395
438
  }
396
439
 
440
+ /**
441
+ * Heading-based HEARTBEAT section writer.
442
+ *
443
+ * @remarks
444
+ * Manages the `# Jeeves Platform Status` section in HEARTBEAT.md.
445
+ * Unlike TOOLS/SOUL/AGENTS (which use HTML comment markers), HEARTBEAT
446
+ * uses markdown headings as markers — this ensures the file passes
447
+ * OpenClaw's heartbeat emptiness check when only headings remain.
448
+ *
449
+ * The section is always at the bottom of the file (H1 to EOF).
450
+ * User heartbeat items above the section are preserved.
451
+ */
452
+ /** The H1 heading that anchors the platform status section. */
453
+ const HEARTBEAT_HEADING = '# Jeeves Platform Status';
454
+ /**
455
+ * Parse the HEARTBEAT.md file content.
456
+ *
457
+ * @param fileContent - Full file content.
458
+ * @returns Parsed result with user zone and component entries.
459
+ */
460
+ function parseHeartbeat(fileContent) {
461
+ const headingIndex = fileContent.indexOf(HEARTBEAT_HEADING);
462
+ if (headingIndex === -1) {
463
+ return {
464
+ userContent: fileContent.trim(),
465
+ found: false,
466
+ entries: [],
467
+ };
468
+ }
469
+ const userContent = fileContent.slice(0, headingIndex).trim();
470
+ const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
471
+ const entries = [];
472
+ const h2Re = /^## (jeeves-\S+?)(?:: declined)?$/gm;
473
+ let match;
474
+ const h2Positions = [];
475
+ while ((match = h2Re.exec(sectionContent)) !== null) {
476
+ const fullHeading = match[0];
477
+ const name = match[1];
478
+ const declined = fullHeading.endsWith(': declined');
479
+ h2Positions.push({ name, declined, start: match.index });
480
+ }
481
+ for (let i = 0; i < h2Positions.length; i++) {
482
+ const pos = h2Positions[i];
483
+ const headingLine = pos.declined
484
+ ? `## ${pos.name}: declined`
485
+ : `## ${pos.name}`;
486
+ const contentStart = pos.start + headingLine.length;
487
+ const contentEnd = i + 1 < h2Positions.length
488
+ ? h2Positions[i + 1].start
489
+ : sectionContent.length;
490
+ const content = sectionContent.slice(contentStart, contentEnd).trim();
491
+ entries.push({
492
+ name: pos.name,
493
+ declined: pos.declined,
494
+ content,
495
+ });
496
+ }
497
+ return { userContent, found: true, entries };
498
+ }
499
+ /**
500
+ * Build the HEARTBEAT section content from entries.
501
+ *
502
+ * @param entries - Component entries to write.
503
+ * @returns The full section string (H1 + H2s).
504
+ */
505
+ function buildHeartbeatSection(entries) {
506
+ const parts = [HEARTBEAT_HEADING];
507
+ for (const entry of entries) {
508
+ if (entry.declined) {
509
+ parts.push(`## ${entry.name}: declined`);
510
+ }
511
+ else if (entry.content) {
512
+ parts.push(`## ${entry.name}`);
513
+ parts.push(entry.content);
514
+ }
515
+ // Healthy components (no content, not declined) get no H2 section
516
+ }
517
+ return parts.join('\n');
518
+ }
519
+ /**
520
+ * Write the HEARTBEAT section to a file.
521
+ *
522
+ * @remarks
523
+ * Replaces everything from `# Jeeves Platform Status` to EOF.
524
+ * Preserves user content above the heading. Uses file-level locking.
525
+ *
526
+ * @param filePath - Absolute path to HEARTBEAT.md.
527
+ * @param entries - Component entries to write.
528
+ */
529
+ async function writeHeartbeatSection(filePath, entries) {
530
+ const dir = dirname(filePath);
531
+ if (!existsSync(dir)) {
532
+ mkdirSync(dir, { recursive: true });
533
+ }
534
+ if (!existsSync(filePath)) {
535
+ writeFileSync(filePath, '', 'utf-8');
536
+ }
537
+ try {
538
+ await withFileLock(filePath, () => {
539
+ const fileContent = readFileSync(filePath, 'utf-8');
540
+ const parsed = parseHeartbeat(fileContent);
541
+ const section = buildHeartbeatSection(entries);
542
+ const parts = [];
543
+ if (parsed.userContent) {
544
+ parts.push(parsed.userContent);
545
+ parts.push('');
546
+ }
547
+ parts.push(section);
548
+ parts.push('');
549
+ atomicWrite(filePath, parts.join('\n'));
550
+ });
551
+ }
552
+ catch (err) {
553
+ const message = err instanceof Error ? err.message : String(err);
554
+ console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${message}`);
555
+ }
556
+ }
557
+
397
558
  /**
398
559
  * Similarity-based cleanup detection for orphaned managed content.
399
560
  *
@@ -747,32 +908,51 @@ async function updateManagedSection(filePath, content, options = {}) {
747
908
  ? `# ${markers.title}\n\n${sectionText}`
748
909
  : sectionText;
749
910
  }
911
+ // Combine beforeContent + userContent for the user zone.
912
+ // When migrating from top→bottom, beforeContent is empty and
913
+ // userContent has the real content. When already at bottom,
914
+ // beforeContent has the user content and userContent is empty.
915
+ const rawUserContent = [parsed.beforeContent, parsed.userContent]
916
+ .filter(Boolean)
917
+ .join('\n\n')
918
+ .trim();
750
919
  // Strip foreign managed blocks from user content (cross-contamination fix)
751
- const userContent = stripForeignMarkers(parsed.userContent, markers);
920
+ const userContent = stripForeignMarkers(rawUserContent, markers);
752
921
  const cleanupNeeded = needsCleanup(newManagedBody, userContent);
753
922
  // Build the full managed block
754
923
  const beginLine = formatBeginMarker(markers.begin, coreVersion);
755
924
  const endLine = formatEndMarker(markers.end);
756
- const parts = [];
757
- if (parsed.beforeContent) {
758
- parts.push(parsed.beforeContent);
759
- parts.push('');
760
- }
761
- parts.push(beginLine);
925
+ const managedParts = [];
926
+ managedParts.push(beginLine);
762
927
  if (cleanupNeeded) {
763
- parts.push('');
764
- parts.push(CLEANUP_FLAG);
928
+ managedParts.push('');
929
+ managedParts.push(CLEANUP_FLAG);
765
930
  }
766
- parts.push('');
767
- parts.push(newManagedBody);
768
- parts.push('');
769
- parts.push(endLine);
770
- if (userContent) {
771
- parts.push('');
772
- parts.push(userContent);
931
+ managedParts.push('');
932
+ managedParts.push(newManagedBody);
933
+ managedParts.push('');
934
+ managedParts.push(endLine);
935
+ const managedBlock = managedParts.join('\n');
936
+ const position = markers.position ?? 'top';
937
+ const fileParts = [];
938
+ if (position === 'bottom') {
939
+ // User content first, managed block at end
940
+ if (userContent) {
941
+ fileParts.push(userContent);
942
+ fileParts.push('');
943
+ }
944
+ fileParts.push(managedBlock);
773
945
  }
774
- parts.push('');
775
- const newFileContent = parts.join('\n');
946
+ else {
947
+ // Managed block first (legacy default), user content below
948
+ fileParts.push(managedBlock);
949
+ if (userContent) {
950
+ fileParts.push('');
951
+ fileParts.push(userContent);
952
+ }
953
+ }
954
+ fileParts.push('');
955
+ const newFileContent = fileParts.join('\n');
776
956
  atomicWrite(filePath, newFileContent);
777
957
  });
778
958
  }
@@ -850,6 +1030,8 @@ At minimum, always brief sub-agents on:
850
1030
 
851
1031
  **Anything important enough to have a permanent cron/heartbeat entry is important enough to be codified into the data flow.**
852
1032
 
1033
+ **HEARTBEAT efficiency:** When HEARTBEAT fires with multiple component alerts, resolve them all in a single conversation. Walk the user through each component in dependency order. Each heartbeat cycle is a full LLM session — resolve all alerts in one pass.
1034
+
853
1035
  ## Messaging Dispatch
854
1036
 
855
1037
  **Same-channel replies:** Don't use the \`message\` tool. It fires immediately, jumping ahead of streaming narration. Just write text as your response. Use \`message\` only for **cross-channel** sends (sending to a different channel or DM than the one you're responding in).
@@ -858,7 +1040,7 @@ At minimum, always brief sub-agents on:
858
1040
 
859
1041
  ## Heartbeat Discipline
860
1042
 
861
- **Default state of HEARTBEAT.md is EMPTY** (comments only). Every heartbeat loads the full system prompt. An empty heartbeat that returns HEARTBEAT_OK still costs a full LLM session.
1043
+ **Default state of HEARTBEAT.md is effectively empty** — the \`# Jeeves Platform Status\` section contains only headings when all components are healthy or declined. No list content, no LLM cost. User heartbeat items (above the platform status section) follow the same transient/termination rules as before.
862
1044
 
863
1045
  Heartbeat items are for **transient, session-requiring work-in-progress ONLY**. Each item must include its own termination condition.
864
1046
 
@@ -919,14 +1101,11 @@ When discovering a new data source, integrate it into the existing data flow pip
919
1101
 
920
1102
  ## Bootstrap Protocol
921
1103
 
922
- When you detect that a Jeeves platform component is missing or not configured:
1104
+ **Proactive platform status:** HEARTBEAT.md is loaded every session. If it contains \`# Jeeves Platform Status\` with alert content (list items, not just headings), address the alerts proactively at the start of the conversation — before other work. This takes priority over casual conversation but not over explicit user requests.
923
1105
 
924
- 1. **Identify** the gap (service not responding on expected port, plugin not registered, config file missing)
925
- 2. **Inform** the user what's missing and what it provides
926
- 3. **Guide** them through installation using the component's CLI install command
927
- 4. **Verify** the installation succeeded (probe the port, check the plugin registration)
1106
+ **Follow the instructions:** Explain the component, ask for consent, execute the command, verify. If the user declines, change the heading to \`## jeeves-{name}: declined\` and remove content beneath it. Do not prompt for declined components. Do not improvise — the HEARTBEAT instructions are authoritative.
928
1107
 
929
- Don't silently work around missing components. The user should know what's available and what isn't.
1108
+ When resolving multiple component alerts, walk the user through each in dependency order (watcher before meta, runner and server independent) within a single conversation rather than one per heartbeat cycle.
930
1109
 
931
1110
  ## Em-Dash Discipline
932
1111
 
@@ -1268,132 +1447,782 @@ async function refreshPlatformContent(options) {
1268
1447
  }
1269
1448
 
1270
1449
  /**
1271
- * Timer-based orchestrator for managed content writing.
1450
+ * Platform-aware service state detection.
1272
1451
  *
1273
1452
  * @remarks
1274
- * `ComponentWriter` manages a component's TOOLS.md section writes
1275
- * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
1276
- * on a configurable prime-interval timer cycle.
1453
+ * Detects whether a system service is installed and running.
1454
+ * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
1277
1455
  */
1278
1456
  /**
1279
- * Orchestrates managed content writing for a single Jeeves component.
1457
+ * Detect the state of a system service by name.
1280
1458
  *
1281
- * @remarks
1282
- * Created via `createComponentWriter()`. Manages a timer that fires
1283
- * at the component's prime-interval, calling `generateToolsContent()`
1284
- * and `refreshPlatformContent()` on each cycle.
1459
+ * @param serviceName - The service name (e.g., 'jeeves-runner').
1460
+ * @returns The detected service state.
1285
1461
  */
1286
- class ComponentWriter {
1287
- timer;
1288
- component;
1289
- configDir;
1290
- /** @internal */
1291
- constructor(component) {
1292
- this.component = component;
1293
- this.configDir = getComponentConfigDir(component.name);
1462
+ function getServiceState(serviceName) {
1463
+ switch (process.platform) {
1464
+ case 'win32':
1465
+ return getServiceStateWindows(serviceName);
1466
+ case 'darwin':
1467
+ return getServiceStateMacOS(serviceName);
1468
+ default:
1469
+ return getServiceStateLinux(serviceName);
1294
1470
  }
1295
- /** The component's config directory path. */
1296
- get componentConfigDir() {
1297
- return this.configDir;
1471
+ }
1472
+ /**
1473
+ * Windows: detect via NSSM.
1474
+ * - Exit code 3 = service does not exist
1475
+ * - "SERVICE_RUNNING" in output = running
1476
+ * - Other output = stopped/paused
1477
+ */
1478
+ function getServiceStateWindows(serviceName) {
1479
+ try {
1480
+ const output = execSync(`nssm status ${serviceName}`, {
1481
+ encoding: 'utf-8',
1482
+ timeout: 5000,
1483
+ stdio: ['pipe', 'pipe', 'pipe'],
1484
+ }).trim();
1485
+ if (output.includes('SERVICE_RUNNING'))
1486
+ return 'running';
1487
+ return 'stopped';
1298
1488
  }
1299
- /** Whether the writer timer is currently running. */
1300
- get isRunning() {
1301
- return this.timer !== undefined;
1489
+ catch (err) {
1490
+ // NSSM exits with code 3 when the service doesn't exist
1491
+ if (isExecError(err) && err.status === 3)
1492
+ return 'not_installed';
1493
+ // Any other error (nssm not found, timeout, etc.) — treat as not installed
1494
+ return 'not_installed';
1302
1495
  }
1303
- /**
1304
- * Start the writer timer.
1305
- *
1306
- * @remarks
1307
- * Performs an immediate first write, then sets up the interval.
1308
- */
1309
- start() {
1310
- if (this.timer)
1311
- return;
1312
- // Fire immediately, then on interval
1313
- void this.cycle();
1314
- this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
1496
+ }
1497
+ /**
1498
+ * Linux: detect via systemd user services.
1499
+ * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
1500
+ * - `systemctl --user is-active {name}.service` returns "active" = running
1501
+ */
1502
+ function getServiceStateLinux(serviceName) {
1503
+ try {
1504
+ execSync(`systemctl --user is-enabled ${serviceName}.service`, {
1505
+ encoding: 'utf-8',
1506
+ timeout: 5000,
1507
+ stdio: ['pipe', 'pipe', 'pipe'],
1508
+ });
1315
1509
  }
1316
- /** Stop the writer timer. */
1317
- stop() {
1318
- if (this.timer) {
1319
- clearInterval(this.timer);
1320
- this.timer = undefined;
1321
- }
1510
+ catch {
1511
+ return 'not_installed';
1322
1512
  }
1323
- /**
1324
- * Execute a single write cycle.
1325
- *
1326
- * @remarks
1327
- * Calls `generateToolsContent()` and writes the component's
1328
- * TOOLS.md section via `updateManagedSection()`. Also calls
1329
- * `refreshPlatformContent()` for shared content maintenance.
1330
- */
1331
- async cycle() {
1332
- try {
1333
- const workspacePath = getWorkspacePath();
1334
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1335
- // Write the component's TOOLS.md section
1336
- const toolsContent = this.component.generateToolsContent();
1337
- await updateManagedSection(toolsPath, toolsContent, {
1338
- mode: 'section',
1339
- sectionId: this.component.sectionId,
1340
- markers: TOOLS_MARKERS,
1341
- coreVersion: CORE_VERSION,
1342
- });
1343
- // Platform content maintenance: SOUL.md, AGENTS.md, Platform section
1344
- await refreshPlatformContent({
1345
- coreVersion: CORE_VERSION,
1346
- componentName: this.component.name,
1347
- componentVersion: this.component.version,
1348
- servicePackage: this.component.servicePackage,
1349
- pluginPackage: this.component.pluginPackage,
1350
- });
1513
+ try {
1514
+ const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
1515
+ encoding: 'utf-8',
1516
+ timeout: 5000,
1517
+ stdio: ['pipe', 'pipe', 'pipe'],
1518
+ }).trim();
1519
+ if (output === 'active')
1520
+ return 'running';
1521
+ return 'stopped';
1522
+ }
1523
+ catch {
1524
+ return 'stopped';
1525
+ }
1526
+ }
1527
+ /**
1528
+ * macOS: detect via launchctl.
1529
+ * - `launchctl list {name}` exits non-zero = not installed
1530
+ * - PID column is `-` or `0` = stopped
1531
+ */
1532
+ function getServiceStateMacOS(serviceName) {
1533
+ try {
1534
+ const output = execSync(`launchctl list ${serviceName}`, {
1535
+ encoding: 'utf-8',
1536
+ timeout: 5000,
1537
+ stdio: ['pipe', 'pipe', 'pipe'],
1538
+ }).trim();
1539
+ // launchctl list output formats:
1540
+ // 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
1541
+ // 2. Plist-style: '"PID" = 1234;'
1542
+ // 3. Single service: first token is the PID or "-"
1543
+ // Try table format: first token is PID
1544
+ const tableMatch = /^(\d+|-)\s/m.exec(output);
1545
+ if (tableMatch) {
1546
+ const pid = tableMatch[1];
1547
+ return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
1351
1548
  }
1352
- catch (err) {
1353
- const message = err instanceof Error ? err.message : String(err);
1354
- console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
1549
+ // Try plist-style: "PID" = <number>;
1550
+ const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
1551
+ if (plistMatch) {
1552
+ return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
1355
1553
  }
1554
+ // If we got output but can't parse it, assume stopped (service exists but state unclear)
1555
+ return 'stopped';
1556
+ }
1557
+ catch {
1558
+ return 'not_installed';
1356
1559
  }
1357
1560
  }
1561
+ /** Type guard for execSync errors with a status code. */
1562
+ function isExecError(err) {
1563
+ return (typeof err === 'object' &&
1564
+ err !== null &&
1565
+ 'status' in err &&
1566
+ typeof err.status === 'number');
1567
+ }
1358
1568
 
1359
1569
  /**
1360
- * Creates a synchronous content accessor backed by an async data source.
1570
+ * Core configuration schema and resolution.
1361
1571
  *
1362
1572
  * @remarks
1363
- * Solves the sync/async gap in `JeevesComponent.generateToolsContent()`:
1364
- * the interface is synchronous, but most components fetch live data from
1365
- * their HTTP service. This utility returns a sync `() => string` that
1366
- * serves the last successfully fetched value while kicking off a background
1367
- * refresh on each call.
1368
- *
1369
- * First call returns `placeholder`. Subsequent calls return the last
1370
- * successfully fetched content. If a refresh fails, the previous good
1371
- * value is retained.
1372
- *
1373
- * @example
1374
- * ```typescript
1375
- * const getContent = createAsyncContentCache({
1376
- * fetch: async () => {
1377
- * const res = await fetch('http://127.0.0.1:1936/status');
1378
- * return formatWatcherStatus(await res.json());
1379
- * },
1380
- * placeholder: '> Initializing watcher status...',
1381
- * });
1382
- *
1383
- * const writer = createComponentWriter({
1384
- * // ...
1385
- * generateToolsContent: getContent,
1386
- * });
1387
- * ```
1573
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
1574
+ * Config resolution order:
1575
+ * 1. Component's own config file
1576
+ * 2. Core config file
1577
+ * 3. Hardcoded library defaults
1388
1578
  */
1579
+ /** Zod schema for a service entry in core config. */
1580
+ const serviceEntrySchema = z.object({
1581
+ /** Service URL (must be a valid URL). */
1582
+ url: z.string().url().describe('Service URL'),
1583
+ });
1584
+ /** Default bind address for all Jeeves services. */
1585
+ const DEFAULT_BIND_ADDRESS = '0.0.0.0';
1586
+ /** Zod schema for the core config file. */
1587
+ const coreConfigSchema = z.object({
1588
+ /** JSON Schema pointer for IDE autocomplete. */
1589
+ $schema: z.string().optional().describe('JSON Schema pointer'),
1590
+ /** Owner identity keys (canonical identityLinks references). */
1591
+ owners: z.array(z.string()).default([]).describe('Owner identity keys'),
1592
+ /**
1593
+ * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
1594
+ * Individual components can override in their own config.
1595
+ */
1596
+ bindAddress: z
1597
+ .string()
1598
+ .default(DEFAULT_BIND_ADDRESS)
1599
+ .describe('Bind address for all Jeeves services'),
1600
+ /** Service URL overrides keyed by service name. */
1601
+ services: z
1602
+ .record(z.string(), serviceEntrySchema)
1603
+ .default({})
1604
+ .describe('Service URL overrides'),
1605
+ /** Registry cache configuration. */
1606
+ registryCache: z
1607
+ .object({
1608
+ /** Cache TTL in seconds for npm registry queries. */
1609
+ ttlSeconds: z
1610
+ .number()
1611
+ .int()
1612
+ .positive()
1613
+ .default(3600)
1614
+ .describe('Cache TTL in seconds'),
1615
+ })
1616
+ .default({})
1617
+ .describe('Registry cache settings'),
1618
+ });
1389
1619
  /**
1390
- * Creates a synchronous content accessor backed by an async data source.
1620
+ * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
1391
1621
  *
1392
- * @param options - Cache configuration.
1393
- * @returns A sync `() => string` suitable for `generateToolsContent`.
1622
+ * @returns A JSON Schema object.
1394
1623
  */
1395
- function createAsyncContentCache(options) {
1396
- const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
1624
+ function generateJsonSchema() {
1625
+ return {
1626
+ $schema: 'http://json-schema.org/draft-07/schema#',
1627
+ title: 'Jeeves Core Configuration',
1628
+ type: 'object',
1629
+ properties: {
1630
+ $schema: { type: 'string' },
1631
+ owners: {
1632
+ type: 'array',
1633
+ items: { type: 'string' },
1634
+ default: [],
1635
+ },
1636
+ bindAddress: {
1637
+ type: 'string',
1638
+ default: '0.0.0.0',
1639
+ description: 'Bind address for all Jeeves services',
1640
+ },
1641
+ services: {
1642
+ type: 'object',
1643
+ additionalProperties: {
1644
+ type: 'object',
1645
+ properties: {
1646
+ url: { type: 'string', format: 'uri' },
1647
+ },
1648
+ required: ['url'],
1649
+ },
1650
+ default: {},
1651
+ },
1652
+ registryCache: {
1653
+ type: 'object',
1654
+ properties: {
1655
+ ttlSeconds: {
1656
+ type: 'integer',
1657
+ minimum: 1,
1658
+ default: 3600,
1659
+ },
1660
+ },
1661
+ default: {},
1662
+ },
1663
+ },
1664
+ };
1665
+ }
1666
+ /**
1667
+ * Load and parse a config file, returning undefined if missing or invalid.
1668
+ *
1669
+ * @param configDir - Directory containing config.json.
1670
+ * @returns Parsed config or undefined.
1671
+ */
1672
+ function loadConfig(configDir) {
1673
+ const configPath = join(configDir, CONFIG_FILE);
1674
+ if (!existsSync(configPath))
1675
+ return undefined;
1676
+ try {
1677
+ const raw = readFileSync(configPath, 'utf-8');
1678
+ const parsed = JSON.parse(raw);
1679
+ return coreConfigSchema.parse(parsed);
1680
+ }
1681
+ catch {
1682
+ return undefined;
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * Service URL resolution.
1688
+ *
1689
+ * @remarks
1690
+ * Resolves the URL for a named Jeeves service using the following
1691
+ * resolution order:
1692
+ * 1. Consumer's own component config
1693
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1694
+ * 3. Default port constants
1695
+ */
1696
+ /**
1697
+ * Resolve the URL for a named Jeeves service.
1698
+ *
1699
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
1700
+ * @param consumerName - Optional consumer component name for config override.
1701
+ * @returns The resolved service URL.
1702
+ * @throws Error if `init()` has not been called or the service is unknown.
1703
+ */
1704
+ function getServiceUrl(serviceName, consumerName) {
1705
+ // 1. Check consumer's own config
1706
+ if (consumerName) {
1707
+ const consumerDir = getComponentConfigDir(consumerName);
1708
+ const consumerConfig = loadConfig(consumerDir);
1709
+ const consumerUrl = consumerConfig?.services[serviceName]?.url;
1710
+ if (consumerUrl)
1711
+ return consumerUrl;
1712
+ }
1713
+ // 2. Check core config
1714
+ const coreDir = getCoreConfigDir();
1715
+ const coreConfig = loadConfig(coreDir);
1716
+ const coreUrl = coreConfig?.services[serviceName]?.url;
1717
+ if (coreUrl)
1718
+ return coreUrl;
1719
+ // 3. Fall back to port constants
1720
+ const port = DEFAULT_PORTS[serviceName];
1721
+ if (port !== undefined) {
1722
+ return `http://127.0.0.1:${String(port)}`;
1723
+ }
1724
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1725
+ }
1726
+
1727
+ /**
1728
+ * Registry version cache for npm package update awareness.
1729
+ *
1730
+ * @remarks
1731
+ * Caches the latest npm registry version in a local JSON file
1732
+ * to avoid expensive `npm view` calls on every refresh cycle.
1733
+ */
1734
+ /**
1735
+ * Check the npm registry for the latest version of a package.
1736
+ *
1737
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
1738
+ * @param cacheDir - Directory to store the cache file.
1739
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
1740
+ * @returns The latest version string, or undefined if the check fails.
1741
+ */
1742
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
1743
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
1744
+ // Check cache first
1745
+ if (existsSync(cachePath)) {
1746
+ try {
1747
+ const raw = readFileSync(cachePath, 'utf-8');
1748
+ const entry = JSON.parse(raw);
1749
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
1750
+ if (age < ttlSeconds * 1000) {
1751
+ return entry.version;
1752
+ }
1753
+ }
1754
+ catch {
1755
+ // Cache corrupt — proceed with fresh check
1756
+ }
1757
+ }
1758
+ // Query npm registry
1759
+ try {
1760
+ const result = execSync(`npm view ${packageName} version`, {
1761
+ encoding: 'utf-8',
1762
+ timeout: 15_000,
1763
+ stdio: ['pipe', 'pipe', 'pipe'],
1764
+ }).trim();
1765
+ if (!result)
1766
+ return undefined;
1767
+ // Write cache
1768
+ if (!existsSync(cacheDir)) {
1769
+ mkdirSync(cacheDir, { recursive: true });
1770
+ }
1771
+ const entry = {
1772
+ version: result,
1773
+ checkedAt: new Date().toISOString(),
1774
+ };
1775
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
1776
+ return result;
1777
+ }
1778
+ catch {
1779
+ return undefined;
1780
+ }
1781
+ }
1782
+
1783
+ /**
1784
+ * HTTP helpers for the OpenClaw plugin SDK.
1785
+ *
1786
+ * @remarks
1787
+ * Thin wrappers around `fetch` that throw on non-OK responses
1788
+ * and handle JSON serialisation/deserialisation.
1789
+ */
1790
+ /**
1791
+ * Fetch a URL with an automatic abort timeout.
1792
+ *
1793
+ * @param url - URL to fetch.
1794
+ * @param timeoutMs - Timeout in milliseconds before aborting.
1795
+ * @param init - Optional `fetch` init options.
1796
+ * @returns The fetch Response object.
1797
+ */
1798
+ async function fetchWithTimeout(url, timeoutMs, init) {
1799
+ const controller = new AbortController();
1800
+ const timeout = setTimeout(() => {
1801
+ controller.abort();
1802
+ }, timeoutMs);
1803
+ try {
1804
+ return await fetch(url, { ...init, signal: controller.signal });
1805
+ }
1806
+ finally {
1807
+ clearTimeout(timeout);
1808
+ }
1809
+ }
1810
+ /**
1811
+ * Fetch JSON from a URL, throwing on non-OK responses.
1812
+ *
1813
+ * @param url - URL to fetch.
1814
+ * @param init - Optional `fetch` init options.
1815
+ * @returns Parsed JSON response body.
1816
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
1817
+ */
1818
+ async function fetchJson(url, init) {
1819
+ const res = await fetch(url, init);
1820
+ if (!res.ok) {
1821
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
1822
+ }
1823
+ return res.json();
1824
+ }
1825
+ /**
1826
+ * POST JSON to a URL and return parsed response.
1827
+ *
1828
+ * @param url - URL to POST to.
1829
+ * @param body - Request body (will be JSON-stringified).
1830
+ * @returns Parsed JSON response body.
1831
+ */
1832
+ async function postJson(url, body) {
1833
+ return fetchJson(url, {
1834
+ method: 'POST',
1835
+ headers: { 'Content-Type': 'application/json' },
1836
+ body: JSON.stringify(body),
1837
+ });
1838
+ }
1839
+
1840
+ /**
1841
+ * HEARTBEAT health orchestration.
1842
+ *
1843
+ * @remarks
1844
+ * Determines the state of each platform component and generates
1845
+ * HEARTBEAT entries with actionable alert text. Applies the dependency
1846
+ * graph for alert suppression and auto-decline.
1847
+ */
1848
+ /** Derive the full service name from a component name. */
1849
+ function toServiceName(name) {
1850
+ return `jeeves-${name}`;
1851
+ }
1852
+ /** Known dependency declarations for platform components. */
1853
+ const COMPONENT_DEPS = {
1854
+ meta: { hard: ['watcher'], soft: [] },
1855
+ server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
1856
+ runner: { hard: [], soft: [] },
1857
+ watcher: { hard: [], soft: [] },
1858
+ };
1859
+ /** "Not installed" alert text for each platform component. Shared with seedContent. */
1860
+ const NOT_INSTALLED_ALERTS = {
1861
+ 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`.',
1862
+ 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`.',
1863
+ 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`.',
1864
+ 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`.',
1865
+ };
1866
+ /** Alert text generators by state. */
1867
+ const ALERT_TEXT = {
1868
+ runner: {
1869
+ not_installed: NOT_INSTALLED_ALERTS['runner'],
1870
+ 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\`.`,
1871
+ 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.',
1872
+ 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`.',
1873
+ },
1874
+ watcher: {
1875
+ not_installed: NOT_INSTALLED_ALERTS['watcher'],
1876
+ 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`.',
1877
+ 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\`.`,
1878
+ 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.',
1879
+ 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`.',
1880
+ },
1881
+ server: {
1882
+ not_installed: NOT_INSTALLED_ALERTS['server'],
1883
+ 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\`.`,
1884
+ 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.',
1885
+ 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`.',
1886
+ },
1887
+ meta: {
1888
+ not_installed: NOT_INSTALLED_ALERTS['meta'],
1889
+ 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.',
1890
+ 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\`.`,
1891
+ 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.',
1892
+ 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`.',
1893
+ },
1894
+ };
1895
+ /** Default Qdrant URL for watcher dependency check. */
1896
+ const QDRANT_URL = 'http://127.0.0.1:6333';
1897
+ /** Health probe timeout in milliseconds. */
1898
+ const PROBE_TIMEOUT_MS = 3000;
1899
+ /**
1900
+ * Check if Qdrant is reachable (watcher dependency).
1901
+ *
1902
+ * @returns True if Qdrant responds.
1903
+ */
1904
+ async function isQdrantAvailable() {
1905
+ try {
1906
+ await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS);
1907
+ return true;
1908
+ }
1909
+ catch {
1910
+ return false;
1911
+ }
1912
+ }
1913
+ /**
1914
+ * Determine the state of a single component.
1915
+ *
1916
+ * @param name - Component name.
1917
+ * @param registry - Current component-versions.json contents.
1918
+ * @param configRoot - Config root path.
1919
+ * @param healthySet - Set of component names known to be healthy (for dep checks).
1920
+ * @returns The component's state.
1921
+ */
1922
+ async function determineComponentState(name, registry, configRoot, healthySet) {
1923
+ // Not in registry = not installed
1924
+ if (!(name in registry))
1925
+ return 'not_installed';
1926
+ // Check hard dependencies
1927
+ const deps = COMPONENT_DEPS[name];
1928
+ for (const hardDep of deps.hard) {
1929
+ if (!healthySet.has(hardDep))
1930
+ return 'deps_missing';
1931
+ }
1932
+ // Watcher-specific: check Qdrant
1933
+ if (name === 'watcher' && !(await isQdrantAvailable())) {
1934
+ return 'deps_missing';
1935
+ }
1936
+ // Check config file
1937
+ const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
1938
+ if (!existsSync(configPath))
1939
+ return 'config_missing';
1940
+ // Fast path: probe HTTP health endpoint
1941
+ try {
1942
+ const url = getServiceUrl(name);
1943
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
1944
+ // Healthy — check for available updates
1945
+ const entry = registry[name];
1946
+ if (entry.pluginPackage && entry.pluginVersion) {
1947
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
1948
+ const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
1949
+ if (latestVersion && gt(latestVersion, entry.pluginVersion)) {
1950
+ return 'update_available';
1951
+ }
1952
+ }
1953
+ return 'healthy';
1954
+ }
1955
+ catch {
1956
+ // Service not responding — classify sub-state
1957
+ const serviceState = getServiceState(toServiceName(name));
1958
+ if (serviceState === 'not_installed')
1959
+ return 'service_not_installed';
1960
+ if (serviceState === 'stopped')
1961
+ return 'service_stopped';
1962
+ // serviceState === 'running' but HTTP failed — still treat as stopped
1963
+ return 'service_stopped';
1964
+ }
1965
+ }
1966
+ /**
1967
+ * Generate the alert text for a component in a given state.
1968
+ *
1969
+ * @param name - Component name.
1970
+ * @param state - The component's state.
1971
+ * @param configRoot - Config root path.
1972
+ * @returns Alert text (list items), or empty string if healthy.
1973
+ */
1974
+ function generateAlertText(name, state, configRoot, registry) {
1975
+ if (state === 'healthy')
1976
+ return '';
1977
+ // Update available — dynamic text with version info
1978
+ if (state === 'update_available') {
1979
+ const entry = registry[name];
1980
+ const currentVersion = entry.pluginVersion ?? 'unknown';
1981
+ const componentConfigDir = join(configRoot, `jeeves-${name}`);
1982
+ const latestVersion = entry.pluginPackage
1983
+ ? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
1984
+ 'unknown')
1985
+ : 'unknown';
1986
+ const installCmd = entry.pluginPackage
1987
+ ? `\`npx ${entry.pluginPackage} install\``
1988
+ : `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
1989
+ return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
1990
+ }
1991
+ const componentAlerts = ALERT_TEXT[name];
1992
+ const alertOrFn = componentAlerts[state];
1993
+ if (!alertOrFn)
1994
+ return '';
1995
+ const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
1996
+ return `- ${text}`;
1997
+ }
1998
+ /**
1999
+ * Orchestrate HEARTBEAT entries for all platform components.
2000
+ *
2001
+ * @param options - Orchestration configuration.
2002
+ * @returns Array of HeartbeatEntry for writeHeartbeatSection.
2003
+ */
2004
+ async function orchestrateHeartbeat(options) {
2005
+ const { coreConfigDir, configRoot, declinedNames } = options;
2006
+ const registry = readComponentVersions(coreConfigDir);
2007
+ // First pass: determine which components are healthy (for dep resolution)
2008
+ const healthySet = new Set();
2009
+ for (const name of PLATFORM_COMPONENTS) {
2010
+ if (declinedNames.has(toServiceName(name)))
2011
+ continue;
2012
+ if (!(name in registry))
2013
+ continue;
2014
+ try {
2015
+ const url = getServiceUrl(name);
2016
+ await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
2017
+ healthySet.add(name);
2018
+ }
2019
+ catch {
2020
+ // Not healthy — will be classified in second pass
2021
+ }
2022
+ }
2023
+ // Second pass: generate entries
2024
+ const entries = [];
2025
+ for (const name of PLATFORM_COMPONENTS) {
2026
+ const fullName = toServiceName(name);
2027
+ // Declined
2028
+ if (declinedNames.has(fullName)) {
2029
+ // Auto-decline dependents of declined hard deps
2030
+ entries.push({ name: fullName, declined: true, content: '' });
2031
+ continue;
2032
+ }
2033
+ const state = await determineComponentState(name, registry, configRoot, healthySet);
2034
+ // Auto-decline if hard dep is declined
2035
+ const deps = COMPONENT_DEPS[name];
2036
+ const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
2037
+ if (hardDepDeclined) {
2038
+ entries.push({ name: fullName, declined: true, content: '' });
2039
+ continue;
2040
+ }
2041
+ const alertText = generateAlertText(name, state, configRoot, registry);
2042
+ entries.push({ name: fullName, declined: false, content: alertText });
2043
+ }
2044
+ // Add soft-dep informational alerts for any healthy component with soft deps
2045
+ for (const entry of entries) {
2046
+ if (entry.declined || entry.content)
2047
+ continue;
2048
+ // Entry is healthy (no alert, not declined) — check for soft deps
2049
+ const shortName = entry.name.replace(/^jeeves-/, '');
2050
+ const deps = COMPONENT_DEPS[shortName];
2051
+ if (!deps.soft.length)
2052
+ continue;
2053
+ const softAlerts = [];
2054
+ for (const dep of deps.soft) {
2055
+ const depFullName = toServiceName(dep);
2056
+ if (declinedNames.has(depFullName))
2057
+ continue;
2058
+ if (!healthySet.has(dep)) {
2059
+ softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
2060
+ }
2061
+ }
2062
+ if (softAlerts.length > 0) {
2063
+ entry.content = softAlerts.join('\n');
2064
+ }
2065
+ }
2066
+ return entries;
2067
+ }
2068
+
2069
+ /**
2070
+ * Timer-based orchestrator for managed content writing.
2071
+ *
2072
+ * @remarks
2073
+ * `ComponentWriter` manages a component's TOOLS.md section writes
2074
+ * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
2075
+ * on a configurable prime-interval timer cycle.
2076
+ */
2077
+ /**
2078
+ * Orchestrates managed content writing for a single Jeeves component.
2079
+ *
2080
+ * @remarks
2081
+ * Created via `createComponentWriter()`. Manages a timer that fires
2082
+ * at the component's prime-interval, calling `generateToolsContent()`
2083
+ * and `refreshPlatformContent()` on each cycle.
2084
+ */
2085
+ class ComponentWriter {
2086
+ timer;
2087
+ component;
2088
+ configDir;
2089
+ /** @internal */
2090
+ constructor(component) {
2091
+ this.component = component;
2092
+ this.configDir = getComponentConfigDir(component.name);
2093
+ }
2094
+ /** The component's config directory path. */
2095
+ get componentConfigDir() {
2096
+ return this.configDir;
2097
+ }
2098
+ /** Whether the writer timer is currently running. */
2099
+ get isRunning() {
2100
+ return this.timer !== undefined;
2101
+ }
2102
+ /**
2103
+ * Start the writer timer.
2104
+ *
2105
+ * @remarks
2106
+ * Performs an immediate first write, then sets up the interval.
2107
+ */
2108
+ start() {
2109
+ if (this.timer)
2110
+ return;
2111
+ // Fire immediately, then on interval
2112
+ void this.cycle();
2113
+ this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
2114
+ }
2115
+ /** Stop the writer timer. */
2116
+ stop() {
2117
+ if (this.timer) {
2118
+ clearInterval(this.timer);
2119
+ this.timer = undefined;
2120
+ }
2121
+ }
2122
+ /**
2123
+ * Execute a single write cycle.
2124
+ *
2125
+ * @remarks
2126
+ * Calls `generateToolsContent()` and writes the component's
2127
+ * TOOLS.md section via `updateManagedSection()`. Also calls
2128
+ * `refreshPlatformContent()` for shared content maintenance.
2129
+ */
2130
+ async cycle() {
2131
+ try {
2132
+ const workspacePath = getWorkspacePath();
2133
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
2134
+ // Write the component's TOOLS.md section
2135
+ const toolsContent = this.component.generateToolsContent();
2136
+ await updateManagedSection(toolsPath, toolsContent, {
2137
+ mode: 'section',
2138
+ sectionId: this.component.sectionId,
2139
+ markers: TOOLS_MARKERS,
2140
+ coreVersion: CORE_VERSION,
2141
+ });
2142
+ // Platform content maintenance: SOUL.md, AGENTS.md, Platform section
2143
+ await refreshPlatformContent({
2144
+ coreVersion: CORE_VERSION,
2145
+ componentName: this.component.name,
2146
+ componentVersion: this.component.version,
2147
+ servicePackage: this.component.servicePackage,
2148
+ pluginPackage: this.component.pluginPackage,
2149
+ });
2150
+ // HEARTBEAT health orchestration
2151
+ const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
2152
+ try {
2153
+ const existingContent = (() => {
2154
+ try {
2155
+ return readFileSync(heartbeatPath, 'utf-8');
2156
+ }
2157
+ catch (err) {
2158
+ // Only swallow "file not found" — let permission errors propagate
2159
+ if (err instanceof Error &&
2160
+ 'code' in err &&
2161
+ err.code === 'ENOENT') {
2162
+ return '';
2163
+ }
2164
+ throw err;
2165
+ }
2166
+ })();
2167
+ const parsed = parseHeartbeat(existingContent);
2168
+ const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
2169
+ const entries = await orchestrateHeartbeat({
2170
+ coreConfigDir: getCoreConfigDir(),
2171
+ configRoot: getConfigRoot(),
2172
+ declinedNames,
2173
+ });
2174
+ await writeHeartbeatSection(heartbeatPath, entries);
2175
+ }
2176
+ catch (err) {
2177
+ const msg = err instanceof Error ? err.message : String(err);
2178
+ console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
2179
+ }
2180
+ }
2181
+ catch (err) {
2182
+ const message = err instanceof Error ? err.message : String(err);
2183
+ console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
2184
+ }
2185
+ }
2186
+ }
2187
+
2188
+ /**
2189
+ * Creates a synchronous content accessor backed by an async data source.
2190
+ *
2191
+ * @remarks
2192
+ * Solves the sync/async gap in `JeevesComponent.generateToolsContent()`:
2193
+ * the interface is synchronous, but most components fetch live data from
2194
+ * their HTTP service. This utility returns a sync `() => string` that
2195
+ * serves the last successfully fetched value while kicking off a background
2196
+ * refresh on each call.
2197
+ *
2198
+ * First call returns `placeholder`. Subsequent calls return the last
2199
+ * successfully fetched content. If a refresh fails, the previous good
2200
+ * value is retained.
2201
+ *
2202
+ * @example
2203
+ * ```typescript
2204
+ * const getContent = createAsyncContentCache({
2205
+ * fetch: async () => {
2206
+ * const res = await fetch('http://127.0.0.1:1936/status');
2207
+ * return formatWatcherStatus(await res.json());
2208
+ * },
2209
+ * placeholder: '> Initializing watcher status...',
2210
+ * });
2211
+ *
2212
+ * const writer = createComponentWriter({
2213
+ * // ...
2214
+ * generateToolsContent: getContent,
2215
+ * });
2216
+ * ```
2217
+ */
2218
+ /**
2219
+ * Creates a synchronous content accessor backed by an async data source.
2220
+ *
2221
+ * @param options - Cache configuration.
2222
+ * @returns A sync `() => string` suitable for `generateToolsContent`.
2223
+ */
2224
+ function createAsyncContentCache(options) {
2225
+ const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
1397
2226
  console.warn('[jeeves] async content cache refresh failed:', err);
1398
2227
  }, } = options;
1399
2228
  let cached = placeholder;
@@ -1492,202 +2321,41 @@ function createComponentWriter(component) {
1492
2321
  }
1493
2322
 
1494
2323
  /**
1495
- * Core configuration schema and resolution.
1496
- *
1497
- * @remarks
1498
- * Core config lives at `{configRoot}/jeeves-core/config.json`.
1499
- * Config resolution order:
1500
- * 1. Component's own config file
1501
- * 2. Core config file
1502
- * 3. Hardcoded library defaults
1503
- */
1504
- /** Zod schema for a service entry in core config. */
1505
- const serviceEntrySchema = z.object({
1506
- /** Service URL (must be a valid URL). */
1507
- url: z.string().url().describe('Service URL'),
1508
- });
1509
- /** Zod schema for the core config file. */
1510
- const coreConfigSchema = z.object({
1511
- /** JSON Schema pointer for IDE autocomplete. */
1512
- $schema: z.string().optional().describe('JSON Schema pointer'),
1513
- /** Owner identity keys (canonical identityLinks references). */
1514
- owners: z.array(z.string()).default([]).describe('Owner identity keys'),
1515
- /** Service URL overrides keyed by service name. */
1516
- services: z
1517
- .record(z.string(), serviceEntrySchema)
1518
- .default({})
1519
- .describe('Service URL overrides'),
1520
- /** Registry cache configuration. */
1521
- registryCache: z
1522
- .object({
1523
- /** Cache TTL in seconds for npm registry queries. */
1524
- ttlSeconds: z
1525
- .number()
1526
- .int()
1527
- .positive()
1528
- .default(3600)
1529
- .describe('Cache TTL in seconds'),
1530
- })
1531
- .default({})
1532
- .describe('Registry cache settings'),
1533
- });
1534
- /**
1535
- * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
1536
- *
1537
- * @returns A JSON Schema object.
1538
- */
1539
- function generateJsonSchema() {
1540
- return {
1541
- $schema: 'http://json-schema.org/draft-07/schema#',
1542
- title: 'Jeeves Core Configuration',
1543
- type: 'object',
1544
- properties: {
1545
- $schema: { type: 'string' },
1546
- owners: {
1547
- type: 'array',
1548
- items: { type: 'string' },
1549
- default: [],
1550
- },
1551
- services: {
1552
- type: 'object',
1553
- additionalProperties: {
1554
- type: 'object',
1555
- properties: {
1556
- url: { type: 'string', format: 'uri' },
1557
- },
1558
- required: ['url'],
1559
- },
1560
- default: {},
1561
- },
1562
- registryCache: {
1563
- type: 'object',
1564
- properties: {
1565
- ttlSeconds: {
1566
- type: 'integer',
1567
- minimum: 1,
1568
- default: 3600,
1569
- },
1570
- },
1571
- default: {},
1572
- },
1573
- },
1574
- };
1575
- }
1576
- /**
1577
- * Load and parse a config file, returning undefined if missing or invalid.
1578
- *
1579
- * @param configDir - Directory containing config.json.
1580
- * @returns Parsed config or undefined.
1581
- */
1582
- function loadConfig(configDir) {
1583
- const configPath = join(configDir, CONFIG_FILE);
1584
- if (!existsSync(configPath))
1585
- return undefined;
1586
- try {
1587
- const raw = readFileSync(configPath, 'utf-8');
1588
- const parsed = JSON.parse(raw);
1589
- return coreConfigSchema.parse(parsed);
1590
- }
1591
- catch {
1592
- return undefined;
1593
- }
1594
- }
1595
-
1596
- /**
1597
- * Service URL resolution.
1598
- *
1599
- * @remarks
1600
- * Resolves the URL for a named Jeeves service using the following
1601
- * resolution order:
1602
- * 1. Consumer's own component config
1603
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1604
- * 3. Default port constants
1605
- */
1606
- /**
1607
- * Resolve the URL for a named Jeeves service.
1608
- *
1609
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
1610
- * @param consumerName - Optional consumer component name for config override.
1611
- * @returns The resolved service URL.
1612
- * @throws Error if `init()` has not been called or the service is unknown.
1613
- */
1614
- function getServiceUrl(serviceName, consumerName) {
1615
- // 1. Check consumer's own config
1616
- if (consumerName) {
1617
- const consumerDir = getComponentConfigDir(consumerName);
1618
- const consumerConfig = loadConfig(consumerDir);
1619
- const consumerUrl = consumerConfig?.services[serviceName]?.url;
1620
- if (consumerUrl)
1621
- return consumerUrl;
1622
- }
1623
- // 2. Check core config
1624
- const coreDir = getCoreConfigDir();
1625
- const coreConfig = loadConfig(coreDir);
1626
- const coreUrl = coreConfig?.services[serviceName]?.url;
1627
- if (coreUrl)
1628
- return coreUrl;
1629
- // 3. Fall back to port constants
1630
- const port = DEFAULT_PORTS[serviceName];
1631
- if (port !== undefined) {
1632
- return `http://127.0.0.1:${String(port)}`;
1633
- }
1634
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1635
- }
1636
-
1637
- /**
1638
- * Registry version cache for npm package update awareness.
2324
+ * Resolve the bind address for a Jeeves service.
1639
2325
  *
1640
2326
  * @remarks
1641
- * Caches the latest npm registry version in a local JSON file
1642
- * to avoid expensive `npm view` calls on every refresh cycle.
2327
+ * Resolution order (four-tier):
2328
+ * 1. Component config `bindAddress` field (if componentName provided)
2329
+ * 2. Core config `bindAddress` field
2330
+ * 3. `JEEVES_BIND_ADDRESS` environment variable
2331
+ * 4. Default: `0.0.0.0`
1643
2332
  */
1644
2333
  /**
1645
- * Check the npm registry for the latest version of a package.
2334
+ * Resolve the bind address for a Jeeves service.
1646
2335
  *
1647
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
1648
- * @param cacheDir - Directory to store the cache file.
1649
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
1650
- * @returns The latest version string, or undefined if the check fails.
2336
+ * @param componentName - Optional component name for component-specific override.
2337
+ * @returns The resolved bind address.
1651
2338
  */
1652
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
1653
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
1654
- // Check cache first
1655
- if (existsSync(cachePath)) {
1656
- try {
1657
- const raw = readFileSync(cachePath, 'utf-8');
1658
- const entry = JSON.parse(raw);
1659
- const age = Date.now() - new Date(entry.checkedAt).getTime();
1660
- if (age < ttlSeconds * 1000) {
1661
- return entry.version;
1662
- }
1663
- }
1664
- catch {
1665
- // Cache corrupt — proceed with fresh check
2339
+ function getBindAddress(componentName) {
2340
+ // Tier 1: Component config (if provided)
2341
+ if (componentName) {
2342
+ const componentConfig = loadConfig(getComponentConfigDir(componentName));
2343
+ if (componentConfig?.bindAddress) {
2344
+ return componentConfig.bindAddress;
1666
2345
  }
1667
2346
  }
1668
- // Query npm registry
1669
- try {
1670
- const result = execSync(`npm view ${packageName} version`, {
1671
- encoding: 'utf-8',
1672
- timeout: 15_000,
1673
- stdio: ['pipe', 'pipe', 'pipe'],
1674
- }).trim();
1675
- if (!result)
1676
- return undefined;
1677
- // Write cache
1678
- if (!existsSync(cacheDir)) {
1679
- mkdirSync(cacheDir, { recursive: true });
1680
- }
1681
- const entry = {
1682
- version: result,
1683
- checkedAt: new Date().toISOString(),
1684
- };
1685
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
1686
- return result;
2347
+ // Tier 2: Core config
2348
+ const coreConfig = loadConfig(getCoreConfigDir());
2349
+ if (coreConfig?.bindAddress) {
2350
+ return coreConfig.bindAddress;
1687
2351
  }
1688
- catch {
1689
- return undefined;
2352
+ // Tier 3: Environment variable
2353
+ const envValue = process.env['JEEVES_BIND_ADDRESS'];
2354
+ if (envValue) {
2355
+ return envValue;
1690
2356
  }
2357
+ // Tier 4: Default
2358
+ return DEFAULT_BIND_ADDRESS;
1691
2359
  }
1692
2360
 
1693
2361
  /**
@@ -1822,6 +2490,7 @@ function ensureCoreConfig(coreConfigDir) {
1822
2490
  * @remarks
1823
2491
  * Uses the same `updateManagedSection()` code path as writer cycles.
1824
2492
  * Creates core config with defaults if missing. Copies templates.
2493
+ * Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
1825
2494
  * Jaccard cleanup detection runs automatically via `updateManagedSection`.
1826
2495
  *
1827
2496
  * @param options - Seeding configuration.
@@ -1830,67 +2499,18 @@ async function seedContent(options) {
1830
2499
  const coreConfigDir = getCoreConfigDir();
1831
2500
  // Ensure core config exists
1832
2501
  ensureCoreConfig(coreConfigDir);
1833
- // Seed content via the same code path as writer cycles
2502
+ // Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
1834
2503
  await refreshPlatformContent({
1835
2504
  coreVersion: options.coreVersion,
1836
2505
  });
1837
- }
1838
-
1839
- /**
1840
- * HTTP helpers for the OpenClaw plugin SDK.
1841
- *
1842
- * @remarks
1843
- * Thin wrappers around `fetch` that throw on non-OK responses
1844
- * and handle JSON serialisation/deserialisation.
1845
- */
1846
- /**
1847
- * Fetch a URL with an automatic abort timeout.
1848
- *
1849
- * @param url - URL to fetch.
1850
- * @param timeoutMs - Timeout in milliseconds before aborting.
1851
- * @param init - Optional `fetch` init options.
1852
- * @returns The fetch Response object.
1853
- */
1854
- async function fetchWithTimeout(url, timeoutMs, init) {
1855
- const controller = new AbortController();
1856
- const timeout = setTimeout(() => {
1857
- controller.abort();
1858
- }, timeoutMs);
1859
- try {
1860
- return await fetch(url, { ...init, signal: controller.signal });
1861
- }
1862
- finally {
1863
- clearTimeout(timeout);
1864
- }
1865
- }
1866
- /**
1867
- * Fetch JSON from a URL, throwing on non-OK responses.
1868
- *
1869
- * @param url - URL to fetch.
1870
- * @param init - Optional `fetch` init options.
1871
- * @returns Parsed JSON response body.
1872
- * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
1873
- */
1874
- async function fetchJson(url, init) {
1875
- const res = await fetch(url, init);
1876
- if (!res.ok) {
1877
- throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
1878
- }
1879
- return res.json();
1880
- }
1881
- /**
1882
- * POST JSON to a URL and return parsed response.
1883
- *
1884
- * @param url - URL to POST to.
1885
- * @param body - Request body (will be JSON-stringified).
1886
- * @returns Parsed JSON response body.
1887
- */
1888
- async function postJson(url, body) {
1889
- return fetchJson(url, {
1890
- method: 'POST',
1891
- headers: { 'Content-Type': 'application/json' },
1892
- body: JSON.stringify(body),
1893
- });
2506
+ // Seed HEARTBEAT.md with "Not installed" alerts for all platform components
2507
+ const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
2508
+ const entries = PLATFORM_COMPONENTS.map((name) => ({
2509
+ name: toServiceName(name),
2510
+ declined: false,
2511
+ content: `- ${NOT_INSTALLED_ALERTS[name]}`,
2512
+ }));
2513
+ await writeHeartbeatSection(heartbeatPath, entries);
1894
2514
  }
1895
2515
 
1896
2516
  /**
@@ -2151,4 +2771,4 @@ function connectionFail(error, baseUrl, pluginId) {
2151
2771
  return fail(error);
2152
2772
  }
2153
2773
 
2154
- export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_CORE_VERSION, DEFAULT_PORTS, META_PORT, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigQueryHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion };
2774
+ 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, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, buildHeartbeatSection, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigQueryHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getBindAddress, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceState, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, orchestrateHeartbeat, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion, writeHeartbeatSection };